Testing: fixing user comments page and profile respects now sidebar width
This commit is contained in:
@@ -2051,39 +2051,7 @@ class CommentSystem {
|
||||
}
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="${commentClass} ${isDeleted ? 'deleted' : ''} ${isPinned ? 'pinned' : ''}" id="c${comment.id}">
|
||||
<div class="comment-avatar">
|
||||
${comment.username ? `<a href="/user/${comment.username}">` : ''}
|
||||
<img src="${comment.avatar_file ? `/a/${comment.avatar_file}` : (comment.avatar ? `/t/${comment.avatar}.webp` : '/a/default.png')}">
|
||||
${comment.username ? `</a>` : ''}
|
||||
</div>
|
||||
<div class="comment-body">
|
||||
<div class="comment-header">
|
||||
<div class="comment-header-left">
|
||||
${pinnedBadge}${comment.username ? `<a href="/user/${comment.username}" class="comment-author" tooltip="ID: ${comment.user_id}" ${comment.username_color ? `style="color: ${comment.username_color}"` : ''}>${this.escapeHtml(comment.display_name || comment.username)}</a>` : '<span class="comment-author">System</span>'}
|
||||
${contextMarker}
|
||||
${backlinkHtml}
|
||||
</div>
|
||||
<a href="#c${comment.id}" class="comment-time timeago" tooltip="${fullDate}" data-iso="${isoDate}" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}">${timeAgo}</a>
|
||||
</div>
|
||||
<div class="comment-content" data-raw="${this.escapeHtml(comment.content)}">${content}</div>
|
||||
${this.renderCommentAttachments(comment.files, comment.content)}
|
||||
${this.renderCommentPoll(comment.poll, comment.id, comment.username)}
|
||||
<div class="comment-footer">
|
||||
<div class="comment-footer-right">
|
||||
<div class="comment-actions">
|
||||
${!isDeleted && currentUserId ? `<button class="reply-btn" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}" title="Reply"><i class="fa-solid fa-reply"></i></button><button class="quote-btn" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}" title="Quote with Text"><i class="fa-solid fa-quote-left"></i></button><button class="report-comment-btn" data-id="${comment.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><i class="fa-solid fa-triangle-exclamation"></i></button>` : ''}
|
||||
${adminButtons}
|
||||
${userDeleteButton}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a href="#c${comment.id}" class="comment-permalink" title="Permalink" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}">#${comment.id}</a>
|
||||
</div>
|
||||
${repliesHtml}
|
||||
`;
|
||||
return `<div class="${commentClass} ${isDeleted ? 'deleted' : ''} ${isPinned ? 'pinned' : ''}" id="c${comment.id}"><div class="comment-avatar">${comment.username ? `<a href="/user/${comment.username}">` : ''}<img src="${comment.avatar_file ? `/a/${comment.avatar_file}` : (comment.avatar ? `/t/${comment.avatar}.webp` : '/a/default.png')}">${comment.username ? `</a>` : ''}</div><div class="comment-body"><div class="comment-header"><div class="comment-header-left">${pinnedBadge}${comment.username ? `<a href="/user/${comment.username}" class="comment-author" tooltip="ID: ${comment.user_id}" ${comment.username_color ? `style="color: ${comment.username_color}"` : ''}>${this.escapeHtml(comment.display_name || comment.username)}</a>` : '<span class="comment-author">System</span>'}${contextMarker}${backlinkHtml}</div><a href="#c${comment.id}" class="comment-time timeago" tooltip="${fullDate}" data-iso="${isoDate}" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}">${timeAgo}</a></div><div class="comment-content" data-raw="${this.escapeHtml(comment.content)}">${content}</div>${this.renderCommentAttachments(comment.files, comment.content)}${this.renderCommentPoll(comment.poll, comment.id, comment.username)}<div class="comment-footer"><div class="comment-footer-right"><div class="comment-actions">${!isDeleted && currentUserId ? `<button class="reply-btn" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}" title="Reply"><i class="fa-solid fa-reply"></i></button><button class="quote-btn" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}" title="Quote with Text"><i class="fa-solid fa-quote-left"></i></button><button class="report-comment-btn" data-id="${comment.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><i class="fa-solid fa-triangle-exclamation"></i></button>` : ''}${adminButtons}${userDeleteButton}</div></div></div></div><a href="#c${comment.id}" class="comment-permalink" title="Permalink" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}">#${comment.id}</a></div>${repliesHtml}`;
|
||||
}
|
||||
|
||||
timeAgo(date) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
class UserCommentSystem {
|
||||
if (!window.UserCommentSystem) {
|
||||
window.UserCommentSystem = class UserCommentSystem {
|
||||
constructor() {
|
||||
this.container = document.getElementById('user-comments-container');
|
||||
this.username = this.container ? this.container.dataset.user : null;
|
||||
@@ -8,6 +9,8 @@ class UserCommentSystem {
|
||||
this.userColor = null;
|
||||
this.customEmojis = UserCommentSystem.emojiCache || {};
|
||||
|
||||
|
||||
|
||||
this.icons = {
|
||||
reply: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 10 20 15 15 20"></polyline><path d="M4 4v7a4 4 0 0 0 4 4h12"></path></svg>`,
|
||||
link: `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>`
|
||||
@@ -23,7 +26,10 @@ class UserCommentSystem {
|
||||
}
|
||||
|
||||
handleLiveEdit(data) {
|
||||
if (!this.container) return;
|
||||
if (!this.container || !document.body.contains(this.container)) {
|
||||
window.removeEventListener('f0ck:comment_edited', this.editListener);
|
||||
return;
|
||||
}
|
||||
const el = document.getElementById('c' + data.comment_id);
|
||||
if (el && this.container.contains(el)) {
|
||||
const contentEl = el.querySelector('.comment-content');
|
||||
@@ -37,7 +43,7 @@ class UserCommentSystem {
|
||||
}
|
||||
|
||||
async init() {
|
||||
this.loadEmojis();
|
||||
await this.loadEmojis();
|
||||
this.loadMore();
|
||||
this.loadMore();
|
||||
this.bindEvents();
|
||||
@@ -129,11 +135,74 @@ class UserCommentSystem {
|
||||
|
||||
renderEmoji(match, name) {
|
||||
if (this.customEmojis && this.customEmojis[name]) {
|
||||
return `<img src="${this.customEmojis[name]}" style="height:60px;vertical-align:middle;" alt="${name}">`;
|
||||
return `<img src="${this.customEmojis[name]}" class="emoji" alt="${match}" title="${match}">`;
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
renderCommentAttachments(files, content = '') {
|
||||
if (!files || files.length === 0) return '';
|
||||
const items = files.map(f => {
|
||||
const url = `/c/${f.dest}`;
|
||||
if (content && content.includes(url)) return ''; // Skip if already rendered in content
|
||||
if (f.mime && f.mime.startsWith('image/')) {
|
||||
return `<a href="${url}" target="_blank" class="cf-attachment cf-image"><img src="${url}" alt="${this.escapeHtml(f.original_filename || 'image')}" loading="lazy"></a>`;
|
||||
} else if (f.mime && f.mime.startsWith('video/')) {
|
||||
return `<div class="cf-attachment cf-video"><video src="${url}" controls preload="metadata"></video></div>`;
|
||||
} else if (f.mime && f.mime.startsWith('audio/')) {
|
||||
return `<div class="cf-attachment cf-audio"><audio src="${url}" controls preload="metadata"></audio></div>`;
|
||||
}
|
||||
return '';
|
||||
}).join('');
|
||||
return items ? `<div class="comment-attachments">${items}</div>` : '';
|
||||
}
|
||||
|
||||
renderCommentPoll(poll, commentId) {
|
||||
if (!poll) return '';
|
||||
const i18n = window.f0ckI18n || {};
|
||||
const session = window.f0ckSession || {};
|
||||
const total = poll.total_votes || 0;
|
||||
const voted = !!poll.user_vote_option_id;
|
||||
const expired = poll.expires_at && new Date(poll.expires_at) < new Date();
|
||||
const isAnon = poll.is_anonymous !== false;
|
||||
|
||||
const optionsHtml = (poll.options || []).map(opt => {
|
||||
const pct = total > 0 ? Math.round((opt.vote_count / total) * 100) : 0;
|
||||
const isVoted = poll.user_vote_option_id === opt.id;
|
||||
const clickable = session.logged_in && !expired && !voted;
|
||||
const voterAvatars = (!isAnon && Array.isArray(opt.voters) && opt.voters.length > 0)
|
||||
? `<div class="poll-option-voters">${opt.voters.map(v => {
|
||||
const u = (v && typeof v === 'object') ? v : { username: String(v || ''), avatar: null, avatar_file: null };
|
||||
const name = String(u.username || '');
|
||||
const src = u.avatar_file ? `/a/${u.avatar_file}` : u.avatar ? `/t/${u.avatar}.webp` : '/a/default.png';
|
||||
return name ? `<a href="/user/${this.escapeHtml(name)}" title="${this.escapeHtml(name)}"><img class="poll-voter-avatar" src="${src}" alt="${this.escapeHtml(name)}" loading="lazy"></a>` : '';
|
||||
}).join('')}</div>`
|
||||
: '';
|
||||
return `<div class="poll-option ${isVoted ? 'poll-option-voted' : ''} ${clickable ? 'poll-option-clickable' : ''}"
|
||||
data-option-id="${opt.id}" data-poll-id="${poll.id}" data-comment-id="${commentId}">
|
||||
<div class="poll-option-bar" style="width:${pct}%"></div>
|
||||
<span class="poll-option-text">${this.escapeHtml(opt.text)}</span>
|
||||
<span class="poll-option-pct">${pct}%</span>
|
||||
${isVoted ? `<i class="fa-solid fa-check poll-vote-check" title="${i18n.poll_voted || 'You voted'}"></i>` : ''}
|
||||
${voterAvatars}
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
const anonBadge = isAnon
|
||||
? `<span class="poll-anon-badge" title="${i18n.poll_anonymous || 'Anonymous'}"><i class="fa-solid fa-user-secret"></i></span>`
|
||||
: `<span class="poll-anon-badge poll-public-badge" title="${i18n.poll_public || 'Public votes'}"><i class="fa-solid fa-eye"></i></span>`;
|
||||
|
||||
return `<div class="comment-poll" data-poll-id="${poll.id}" data-is-anonymous="${isAnon ? '1' : '0'}">
|
||||
<div class="poll-question">${this.escapeHtml(poll.question)}</div>
|
||||
<div class="poll-options">${optionsHtml}</div>
|
||||
<div class="poll-footer">
|
||||
<span class="poll-total">${total} ${total === 1 ? (i18n.poll_vote_single || 'vote') : (i18n.poll_votes || 'votes')}</span>
|
||||
${anonBadge}
|
||||
${expired ? `<span class="poll-expired-badge">${i18n.poll_expired || 'Poll closed'}</span>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
renderCommentContent(content, itemId = null) {
|
||||
if (!content) return '';
|
||||
|
||||
@@ -152,7 +221,7 @@ class UserCommentSystem {
|
||||
let escaped = this.escapeHtml(content).replace(/>/g, ">");
|
||||
|
||||
// 2. Mentions
|
||||
const mentionRegex = /(?<!\[)@([a-zA-Z0-9_\-\.]+)(?!\])/g;
|
||||
const mentionRegex = /(?<!\[)@([a-zA-Z0-9_\-\.]+)(?!\])|\[@([^\]]+)\]/g;
|
||||
|
||||
const siteOrigin = window.location.origin;
|
||||
const renderer = new marked.Renderer();
|
||||
@@ -191,6 +260,32 @@ class UserCommentSystem {
|
||||
return `<a href="${href}"${titleAttr}>${displayText}</a>`;
|
||||
};
|
||||
|
||||
renderer.image = (href, title, text) => {
|
||||
const src = (typeof href === 'object' && href !== null) ? (href.href || '') : (href || '');
|
||||
const imgHtml = `<img src="${src}" alt="${text || ''}"${title ? ` title="${title}"` : ''} onerror="this.onerror=null; this.outerHTML='<span class=\\'broken-image-text\\'>[image not found]</span>';">`;
|
||||
if (window.f0ckSession?.is_admin && src && src.startsWith('/c/')) {
|
||||
const filename = src.substring(3); // Remove '/c/'
|
||||
return `<span class="image-embed-wrap">${imgHtml}<button class="admin-delete-attachment-btn" data-filename="${filename}" title="Delete attachment">[x]</button></span>`;
|
||||
}
|
||||
return imgHtml;
|
||||
};
|
||||
|
||||
// Pre-compile regexes for image/video/audio embeds matching comments.js
|
||||
const escapedSiteHost = window.location.host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const allowedHosts = [escapedSiteHost];
|
||||
if (window.f0ckAllowedImages && Array.isArray(window.f0ckAllowedImages)) {
|
||||
window.f0ckAllowedImages.forEach(h => {
|
||||
const escapedHost = h.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
allowedHosts.push(`(?:[a-z0-9-]+\\.)*${escapedHost}`);
|
||||
});
|
||||
}
|
||||
const hostsRegexPart = allowedHosts.join('|');
|
||||
const domainOrRelative = `(?:(?:https?:\\/\\/|\\/\\/)?(?:${hostsRegexPart})|(?:(?<!\\S)|(?<=\\]))(?=\\/[a-zA-Z0-9_\\-]))`;
|
||||
const safeS = `(?:(?!https?:\\/\\/)\\S)`;
|
||||
const imageRegex = new RegExp(`(?<![\\(\\[])(${domainOrRelative}(?:\\/${safeS}+\\.(?:jpg|jpeg|png|gif|webp)(?:\\?${safeS}+)?(?:#gif)?))(?![\\)\\]])`, 'gi');
|
||||
const rawVideoRegex = new RegExp(`(?<![\\(\\[])(${domainOrRelative}(?:\\/[^\\s\\[\\]\\(\\)]*\\.(?:mp4|webm|ogv|mov)(?:\\?[^\\s\\[\\]\\(\\)]+)?(?:#gif)?))`, 'gi');
|
||||
const rawAudioRegex = new RegExp(`(?<![\\(\\[])(${domainOrRelative}(?:\\/[^\\s\\[\\]\\(\\)]*\\.(?:mp3|ogg|wav|flac|aac|opus|m4a)(?:\\?[^\\s\\[\\]\\(\\)]+)?))`, 'gi');
|
||||
|
||||
// 3. Line-by-line rendering to avoid paragraph collapsing and recursion
|
||||
const renderedLines = escaped.split('\n').map(line => {
|
||||
const trimmed = line.trimStart();
|
||||
@@ -203,7 +298,13 @@ class UserCommentSystem {
|
||||
if (line.length > 10000) return line;
|
||||
if (!line.trim()) return ' ';
|
||||
|
||||
let processedLine = line.replace(mentionRegex, '[@$1](/user/$1)');
|
||||
let processedLine = line;
|
||||
|
||||
// Handle Mentions
|
||||
processedLine = processedLine.replace(mentionRegex, (match, g1, g2) => {
|
||||
const user = g1 || g2;
|
||||
return `<a href="/user/${encodeURIComponent(user)}" class="mention">@${user}</a>`;
|
||||
});
|
||||
|
||||
// Handle Comment Context Links (>>ID)
|
||||
processedLine = processedLine.replace(/(?<!\w)>>(\d+)/g, (match, id) => {
|
||||
@@ -211,6 +312,28 @@ class UserCommentSystem {
|
||||
return `<a href="${targetHref}" class="comment-context-link" data-id="${id}">>>${id}</a>`;
|
||||
});
|
||||
|
||||
// Handle Image Embeds
|
||||
processedLine = processedLine.replace(imageRegex, (match, url) => {
|
||||
let fullUrl = url;
|
||||
if (!url.startsWith('http') && !url.startsWith('//') && !url.startsWith('/')) {
|
||||
fullUrl = '//' + url;
|
||||
}
|
||||
return ``;
|
||||
});
|
||||
|
||||
// Handle Raw Video/Audio links so Marked converts them to <a>
|
||||
processedLine = processedLine.replace(rawVideoRegex, (match, url) => {
|
||||
let fullUrl = url;
|
||||
if (!url.startsWith('http') && !url.startsWith('//') && !url.startsWith('/')) fullUrl = '//' + url;
|
||||
return `[video](${fullUrl})`;
|
||||
});
|
||||
|
||||
processedLine = processedLine.replace(rawAudioRegex, (match, url) => {
|
||||
let fullUrl = url;
|
||||
if (!url.startsWith('http') && !url.startsWith('//') && !url.startsWith('/')) fullUrl = '//' + url;
|
||||
return `[audio](${fullUrl})`;
|
||||
});
|
||||
|
||||
const escapedAsterisks = processedLine.replace(/\*/g, '\\*');
|
||||
let rendered = marked.parseInline ? marked.parseInline(escapedAsterisks, { renderer: renderer }) : marked.parse(escapedAsterisks, { renderer: renderer }).replace(/<p>|<\/p>/g, '');
|
||||
|
||||
@@ -264,35 +387,7 @@ class UserCommentSystem {
|
||||
const fullDate = new Date(c.created_at).toISOString();
|
||||
const content = this.renderCommentContent(c.content, c.item_id);
|
||||
|
||||
// Replicating the structure of comments.js but adapting for the list view
|
||||
// We add a header indicating which item this comment belongs to
|
||||
|
||||
return `
|
||||
<div class="comment" id="c${c.id}">
|
||||
<div class="comment-avatar">
|
||||
<a href="/${c.item_id}">
|
||||
<img src="/t/${c.item_id}.webp" alt="">
|
||||
</a>
|
||||
</div>
|
||||
<div class="comment-body">
|
||||
<div class="comment-header">
|
||||
<div class="comment-header-left">
|
||||
<span class="comment-author" tooltip="ID: ${c.user_id}" ${this.userColor ? `style="color: ${this.userColor}"` : ''}>${this.username}</span>
|
||||
</div>
|
||||
<span class="comment-time timeago" title="${fullDate}">${timeAgo}</span>
|
||||
</div>
|
||||
<div class="comment-content">${content}</div>
|
||||
<div class="comment-footer">
|
||||
<div class="comment-footer-right">
|
||||
<div class="comment-actions">
|
||||
${window.f0ckSession && window.f0ckSession.logged_in ? `<button class="report-comment-btn" data-id="${c.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 512 512" fill="currentColor"><path d="M506.3 417l-213.3-364c-16.3-28-57.5-28-73.8 0l-213.2 364C-10.6 445.1 9.7 480 42.7 480h426.6C502.5 480 522.6 445.1 506.3 417zM256 384c-14.1 0-25.6-11.5-25.6-25.6 0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6C281.6 372.5 270.1 384 256 384zM281.6 264.4c0 14.1-11.5 25.6-25.6 25.6-14.1 0-25.6-11.5-25.6-25.6v-96c0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6V264.4z"/></svg></button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/${c.item_id}#c${c.id}" class="comment-permalink" title="Permalink">#${c.id}</a>
|
||||
</div>
|
||||
`;
|
||||
return `<div class="comment" id="c${c.id}"><div class="comment-avatar"><a href="/${c.item_id}"><img src="/t/${c.item_id}.webp" alt=""></a></div><div class="comment-body"><div class="comment-header"><div class="comment-header-left"><span class="comment-author" tooltip="ID: ${c.user_id}" ${this.userColor ? `style="color: ${this.userColor}"` : ''}>${this.username}</span></div><span class="comment-time timeago" title="${fullDate}">${timeAgo}</span></div><div class="comment-content" data-raw="${this.escapeHtml(c.content)}">${content}</div>${this.renderCommentAttachments(c.files, c.content)}${this.renderCommentPoll(c.poll, c.id)}<div class="comment-footer"><div class="comment-footer-right"><div class="comment-actions">${window.f0ckSession && window.f0ckSession.logged_in ? `<button class="report-comment-btn" data-id="${c.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 512 512" fill="currentColor"><path d="M506.3 417l-213.3-364c-16.3-28-57.5-28-73.8 0l-213.2 364C-10.6 445.1 9.7 480 42.7 480h426.6C502.5 480 522.6 445.1 506.3 417zM256 384c-14.1 0-25.6-11.5-25.6-25.6 0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6C281.6 372.5 270.1 384 256 384zM281.6 264.4c0 14.1-11.5 25.6-25.6 25.6-14.1 0-25.6-11.5-25.6-25.6v-96c0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6V264.4z"/></svg></button>` : ''}</div></div></div></div><a href="/${c.item_id}#c${c.id}" class="comment-permalink" title="Permalink">#${c.id}</a></div>`;
|
||||
}
|
||||
|
||||
startLiveTimestamps() {
|
||||
@@ -336,15 +431,21 @@ class UserCommentSystem {
|
||||
return div.innerHTML;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initializer for AJAX and standard load
|
||||
window.initUserComments = () => {
|
||||
// Prevent multiple instances if already running on this container
|
||||
if (document.getElementById('user-comments-container')) {
|
||||
const container = document.getElementById('user-comments-container');
|
||||
if (container && !container.dataset.initialized) {
|
||||
container.dataset.initialized = 'true';
|
||||
new UserCommentSystem();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
if (document.readyState === 'loading') {
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
window.initUserComments();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
window.initUserComments();
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ const sendJson = (res, data, code = 200) => {
|
||||
res.end(JSON.stringify(data));
|
||||
};
|
||||
|
||||
// One-time migration: ensure comment_files table exists
|
||||
db`CREATE TABLE IF NOT EXISTS public.comment_files (
|
||||
id SERIAL PRIMARY KEY,
|
||||
comment_id INTEGER REFERENCES public.comments(id) ON DELETE CASCADE,
|
||||
@@ -29,6 +28,8 @@ db`CREATE SEQUENCE IF NOT EXISTS comment_files_id_seq`.catch(() => { });
|
||||
db`ALTER TABLE comment_files ALTER COLUMN id SET DEFAULT nextval('comment_files_id_seq')`.catch(() => { });
|
||||
db`CREATE INDEX IF NOT EXISTS idx_comment_files_comment_id ON public.comment_files(comment_id)`.catch(() => { });
|
||||
db`CREATE INDEX IF NOT EXISTS idx_comment_files_checksum ON public.comment_files(checksum)`.catch(() => { });
|
||||
db`ALTER TABLE public.comment_files ADD CONSTRAINT comment_files_pkey PRIMARY KEY (id)`.catch(() => { });
|
||||
db`ALTER TABLE public.comment_files REPLICA IDENTITY DEFAULT`.catch(() => { });
|
||||
|
||||
/**
|
||||
* Parse multipart form data supporting multiple files with the same field name.
|
||||
|
||||
@@ -205,13 +205,122 @@ export default (router, tpl) => {
|
||||
|
||||
// Let's modify comments content in-place (or new array) before mapping
|
||||
const mentionsProcessed = await f0cklib.processMentions(comments);
|
||||
const processedComments = mentionsProcessed.map(c => {
|
||||
let processedComments = mentionsProcessed.map(c => {
|
||||
return {
|
||||
...c,
|
||||
content: c.content
|
||||
};
|
||||
});
|
||||
|
||||
// Fetch file attachments for all fetched comments
|
||||
if (processedComments.length > 0) {
|
||||
const commentIds = processedComments.map(c => c.id);
|
||||
try {
|
||||
const files = await db`
|
||||
SELECT id, comment_id, dest, mime, size, original_filename
|
||||
FROM comment_files
|
||||
WHERE comment_id = ANY(${commentIds}::int[])
|
||||
ORDER BY id ASC
|
||||
`;
|
||||
const filesMap = new Map();
|
||||
for (const f of files) {
|
||||
if (!filesMap.has(f.comment_id)) filesMap.set(f.comment_id, []);
|
||||
filesMap.get(f.comment_id).push(f);
|
||||
}
|
||||
for (const c of processedComments) {
|
||||
c.files = filesMap.get(c.id) || [];
|
||||
}
|
||||
} catch (e) {
|
||||
for (const c of processedComments) c.files = [];
|
||||
}
|
||||
|
||||
// Fetch poll data for comments
|
||||
if (cfg.websrv.enable_comment_polls) {
|
||||
try {
|
||||
const commentIds = processedComments.map(c => c.id);
|
||||
const pollRows = await db`
|
||||
SELECT
|
||||
cp.id as poll_id,
|
||||
cp.comment_id,
|
||||
cp.question,
|
||||
cp.expires_at,
|
||||
COALESCE(cp.is_anonymous, true) as is_anonymous,
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'id', cpo.id,
|
||||
'text', cpo.text,
|
||||
'sort_order', cpo.sort_order,
|
||||
'vote_count', COALESCE(vote_counts.cnt, 0)
|
||||
) ORDER BY cpo.sort_order ASC, cpo.id ASC
|
||||
) AS options,
|
||||
COALESCE(SUM(vote_counts.cnt), 0)::int AS total_votes
|
||||
FROM comment_polls cp
|
||||
JOIN comment_poll_options cpo ON cpo.poll_id = cp.id
|
||||
LEFT JOIN (
|
||||
SELECT option_id, COUNT(*) AS cnt
|
||||
FROM comment_poll_votes
|
||||
GROUP BY option_id
|
||||
) vote_counts ON vote_counts.option_id = cpo.id
|
||||
WHERE cp.comment_id = ANY(${commentIds}::int[])
|
||||
GROUP BY cp.id, cp.comment_id, cp.question, cp.expires_at, cp.is_anonymous
|
||||
`;
|
||||
// For non-anonymous polls, fetch voter names
|
||||
const nonAnonIds = pollRows.filter(p => !p.is_anonymous).map(p => p.poll_id);
|
||||
let votersByOption = new Map();
|
||||
if (nonAnonIds.length > 0) {
|
||||
const voterRows = await db`
|
||||
SELECT cpv.option_id, u."user" as username, uo.avatar, uo.avatar_file
|
||||
FROM comment_poll_votes cpv
|
||||
JOIN public."user" u ON u.id = cpv.user_id
|
||||
LEFT JOIN public.user_options uo ON uo.user_id = cpv.user_id
|
||||
WHERE cpv.poll_id = ANY(${nonAnonIds}::int[])
|
||||
`;
|
||||
for (const v of voterRows) {
|
||||
if (!votersByOption.has(v.option_id)) votersByOption.set(v.option_id, []);
|
||||
votersByOption.get(v.option_id).push({ username: v.username, avatar: v.avatar, avatar_file: v.avatar_file });
|
||||
}
|
||||
}
|
||||
const pollMap = new Map();
|
||||
for (const p of pollRows) {
|
||||
const options = p.is_anonymous
|
||||
? p.options
|
||||
: p.options.map(o => ({ ...o, voters: votersByOption.get(o.id) || [] }));
|
||||
pollMap.set(p.comment_id, {
|
||||
id: p.poll_id,
|
||||
question: p.question,
|
||||
expires_at: p.expires_at,
|
||||
is_anonymous: p.is_anonymous,
|
||||
options,
|
||||
total_votes: parseInt(p.total_votes) || 0,
|
||||
user_vote_option_id: null
|
||||
});
|
||||
}
|
||||
// Fill in per-user poll votes if logged in
|
||||
if (req.session && pollRows.length > 0) {
|
||||
const pollIds = pollRows.map(p => p.poll_id);
|
||||
try {
|
||||
const votes = await db`
|
||||
SELECT poll_id, option_id FROM comment_poll_votes
|
||||
WHERE poll_id = ANY(${pollIds}::int[]) AND user_id = ${req.session.id}
|
||||
`;
|
||||
const voteMap = new Map(votes.map(v => [v.poll_id, v.option_id]));
|
||||
for (const [comment_id, poll] of pollMap.entries()) {
|
||||
poll.user_vote_option_id = voteMap.get(poll.id) || null;
|
||||
}
|
||||
} catch (e) { /* graceful */ }
|
||||
}
|
||||
for (const c of processedComments) {
|
||||
c.poll = pollMap.get(c.id) || null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[USER_COMMENTS] Poll fetch error:', e.message);
|
||||
for (const c of processedComments) c.poll = null;
|
||||
}
|
||||
} else {
|
||||
for (const c of processedComments) c.poll = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (isJson) {
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
|
||||
@@ -25,4 +25,4 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- Include local script for this page -->
|
||||
<script src="/s/js/user_comments.js?v=1"></script>
|
||||
<script src="/s/js/user_comments.js?v=2"></script>
|
||||
@@ -1,7 +1,9 @@
|
||||
@include(snippets/header)
|
||||
|
||||
<div class="pagewrapper">
|
||||
<div id="main">
|
||||
@include(comments_user-partial)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@include(snippets/footer)
|
||||
Reference in New Issue
Block a user