This commit is contained in:
2026-05-29 20:49:26 +02:00
parent 7e7da4030d
commit e0e0456768
10 changed files with 167 additions and 13 deletions

View File

@@ -2847,6 +2847,7 @@ CREATE TABLE IF NOT EXISTS public.comment_polls (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
comment_id INTEGER NOT NULL REFERENCES public.comments(id) ON DELETE CASCADE, comment_id INTEGER NOT NULL REFERENCES public.comments(id) ON DELETE CASCADE,
question TEXT NOT NULL, question TEXT NOT NULL,
is_anonymous BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE DEFAULT NULL, expires_at TIMESTAMP WITH TIME ZONE DEFAULT NULL,
UNIQUE(comment_id) UNIQUE(comment_id)

View File

@@ -2823,6 +2823,84 @@ body.layout-legacy #comments-container.faded-out {
color: #ff4444; color: #ff4444;
} }
.poll-anon-badge {
font-size: 0.78em;
color: #555;
display: flex;
align-items: center;
gap: 3px;
}
.poll-public-badge {
color: #6a9fb5;
}
.poll-anon-toggle {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 0.8em;
color: #aaa;
cursor: pointer;
user-select: none;
border: 1px solid rgba(255,255,255,0.12);
padding: 3px 10px;
transition: color 0.15s, border-color 0.15s;
}
.poll-anon-toggle:has(.poll-anon-checkbox:checked) {
color: var(--accent);
border-color: var(--accent);
}
.poll-anon-checkbox {
display: none;
}
.poll-option-voters {
display: flex;
flex-wrap: wrap;
gap: 3px;
margin-top: 4px;
align-items: center;
}
.poll-voter-avatar {
width: 20px;
height: 20px;
border-radius: 50%;
object-fit: cover;
border: 1px solid rgba(255,255,255,0.1);
transition: transform 0.12s, border-color 0.12s;
display: block;
}
.poll-option-voters a:hover .poll-voter-avatar {
transform: scale(1.2);
border-color: var(--accent);
}
.sidebar-poll-preview {
display: inline-flex;
align-items: center;
gap: 5px;
margin-top: 4px;
font-size: 0.8em;
color: #888;
text-decoration: none;
font-style: italic;
transition: color 0.12s;
}
.sidebar-poll-preview i {
color: var(--accent);
opacity: 0.7;
}
.sidebar-poll-preview:hover {
color: var(--accent);
}
.comments-list { .comments-list {
display: flex; display: flex;

View File

@@ -2116,19 +2116,29 @@ class CommentSystem {
const total = poll.total_votes || 0; const total = poll.total_votes || 0;
const voted = !!poll.user_vote_option_id; const voted = !!poll.user_vote_option_id;
const expired = poll.expires_at && new Date(poll.expires_at) < new Date(); const expired = poll.expires_at && new Date(poll.expires_at) < new Date();
const isAnon = poll.is_anonymous !== false;
const canDelete = session.logged_in && (session.is_admin || session.is_moderator || session.user === commentUsername); const canDelete = session.logged_in && (session.is_admin || session.is_moderator || session.user === commentUsername);
const optionsHtml = (poll.options || []).map(opt => { const optionsHtml = (poll.options || []).map(opt => {
const pct = total > 0 ? Math.round((opt.vote_count / total) * 100) : 0; const pct = total > 0 ? Math.round((opt.vote_count / total) * 100) : 0;
const isVoted = poll.user_vote_option_id === opt.id; const isVoted = poll.user_vote_option_id === opt.id;
const clickable = session.logged_in && !expired && (!voted); 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' : ''}" 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}"> data-option-id="${opt.id}" data-poll-id="${poll.id}" data-comment-id="${commentId}">
<div class="poll-option-bar" style="width:${pct}%"></div> <div class="poll-option-bar" style="width:${pct}%"></div>
<span class="poll-option-text">${this.escapeHtml(opt.text)}</span> <span class="poll-option-text">${this.escapeHtml(opt.text)}</span>
<span class="poll-option-pct">${pct}%</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>` : ''} ${isVoted ? `<i class="fa-solid fa-check poll-vote-check" title="${i18n.poll_voted || 'You voted'}"></i>` : ''}
${voterAvatars}
</div>`; </div>`;
}).join(''); }).join('');
@@ -2136,11 +2146,16 @@ class CommentSystem {
? `<button class="poll-delete-btn" data-poll-id="${poll.id}" title="${i18n.poll_delete || 'Delete poll'}"><i class="fa-solid fa-trash-can"></i></button>` ? `<button class="poll-delete-btn" data-poll-id="${poll.id}" title="${i18n.poll_delete || 'Delete poll'}"><i class="fa-solid fa-trash-can"></i></button>`
: ''; : '';
return `<div class="comment-poll" data-poll-id="${poll.id}"> 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-question">${this.escapeHtml(poll.question)}</div>
<div class="poll-options">${optionsHtml}</div> <div class="poll-options">${optionsHtml}</div>
<div class="poll-footer"> <div class="poll-footer">
<span class="poll-total">${total} ${total === 1 ? (i18n.poll_vote_single || 'vote') : (i18n.poll_votes || 'votes')}</span> <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>` : ''} ${expired ? `<span class="poll-expired-badge">${i18n.poll_expired || 'Poll closed'}</span>` : ''}
${deleteBtn} ${deleteBtn}
</div> </div>
@@ -2633,6 +2648,10 @@ class CommentSystem {
</div> </div>
<div class="poll-builder-actions"> <div class="poll-builder-actions">
<button class="poll-add-option-btn" type="button"><i class="fa-solid fa-plus"></i> ${i18n.poll_add_option || 'Add option'}</button> <button class="poll-add-option-btn" type="button"><i class="fa-solid fa-plus"></i> ${i18n.poll_add_option || 'Add option'}</button>
<label class="poll-anon-toggle">
<input type="checkbox" class="poll-anon-checkbox" checked>
<i class="fa-solid fa-user-secret"></i> ${i18n.poll_anonymous || 'Anonymous'}
</label>
<button class="poll-remove-btn" type="button"><i class="fa-solid fa-xmark"></i> ${i18n.poll_remove || 'Remove poll'}</button> <button class="poll-remove-btn" type="button"><i class="fa-solid fa-xmark"></i> ${i18n.poll_remove || 'Remove poll'}</button>
</div> </div>
`; `;
@@ -2698,6 +2717,7 @@ class CommentSystem {
const i18n = window.f0ckI18n || {}; const i18n = window.f0ckI18n || {};
const total = data.total_votes || 0; const total = data.total_votes || 0;
pollWidget.querySelector('.poll-total').textContent = `${total} ${total === 1 ? (i18n.poll_vote_single || 'vote') : (i18n.poll_votes || 'votes')}`; pollWidget.querySelector('.poll-total').textContent = `${total} ${total === 1 ? (i18n.poll_vote_single || 'vote') : (i18n.poll_votes || 'votes')}`;
const isAnon = pollWidget.dataset.isAnonymous !== '0';
(data.options || []).forEach(updated => { (data.options || []).forEach(updated => {
const el = pollWidget.querySelector(`.poll-option[data-option-id="${updated.id}"]`); const el = pollWidget.querySelector(`.poll-option[data-option-id="${updated.id}"]`);
if (!el) return; if (!el) return;
@@ -2710,6 +2730,21 @@ class CommentSystem {
el.insertAdjacentHTML('beforeend', `<i class="fa-solid fa-check poll-vote-check" title="${i18n.poll_voted || 'You voted'}"></i>`); el.insertAdjacentHTML('beforeend', `<i class="fa-solid fa-check poll-vote-check" title="${i18n.poll_voted || 'You voted'}"></i>`);
} }
} }
// Update voter list for public polls
if (!isAnon && Array.isArray(updated.voters)) {
let votersEl = el.querySelector('.poll-option-voters');
if (updated.voters.length > 0) {
const html = updated.voters.map(v => {
const u = typeof v === 'object' ? v : { username: v, avatar: null, avatar_file: null };
const src = u.avatar_file ? `/a/${u.avatar_file}` : u.avatar ? `/t/${u.avatar}.webp` : '/a/default.png';
return `<a href="/user/${u.username}" title="${u.username}"><img class="poll-voter-avatar" src="${src}" alt="${u.username}" loading="lazy"></a>`;
}).join('');
if (votersEl) votersEl.innerHTML = html;
else el.insertAdjacentHTML('beforeend', `<div class="poll-option-voters">${html}</div>`);
} else if (votersEl) {
votersEl.remove();
}
}
}); });
}).catch(() => { }).catch(() => {
if (window.flashMessage) window.flashMessage('Network error', 2500, 'error'); if (window.flashMessage) window.flashMessage('Network error', 2500, 'error');
@@ -3150,8 +3185,9 @@ class CommentSystem {
const options = [...pollBuilder.querySelectorAll('.poll-option-input')] const options = [...pollBuilder.querySelectorAll('.poll-option-input')]
.map(i => i.value.trim()) .map(i => i.value.trim())
.filter(Boolean); .filter(Boolean);
const isAnonymous = pollBuilder.querySelector('.poll-anon-checkbox')?.checked !== false;
if (question && options.length >= 2) { if (question && options.length >= 2) {
pollPayload = { question, options }; pollPayload = { question, options, is_anonymous: isAnonymous };
} }
} }

View File

@@ -410,10 +410,17 @@
return items ? `<div class="comment-attachments">${items}</div>` : ''; return items ? `<div class="comment-attachments">${items}</div>` : '';
}; };
const renderSidebarPoll = (poll, commentId, itemId) => {
if (!poll) return '';
const href = (itemId && commentId) ? `/${itemId}#c${commentId}` : (commentId ? `#sc${commentId}` : '#');
return `<a class="sidebar-poll-preview" href="${href}"><i class="fa-solid fa-chart-bar"></i> ${escapeHtml(poll.question)}</a>`;
};
const renderActivityItem = (c) => { const renderActivityItem = (c) => {
const rawContent = c.content || c.body || ''; const rawContent = c.content || c.body || '';
const displayContent = renderCommentContent(rawContent, c.id, c.item_id); const displayContent = renderCommentContent(rawContent, c.id, c.item_id);
const attachmentsHtml = renderCommentAttachments(c.files, rawContent); const attachmentsHtml = renderCommentAttachments(c.files, rawContent);
const pollHtml = renderSidebarPoll(c.poll, c.id, c.item_id);
// Build avatar URL — same priority as the rest of the app // Build avatar URL — same priority as the rest of the app
let avatarSrc = '/a/default.png'; let avatarSrc = '/a/default.png';
@@ -472,12 +479,13 @@
</div> </div>
<span class="comment-time timeago" style="font-size: 0.75em;"${tsAttr}>${timeStr}</span> <span class="comment-time timeago" style="font-size: 0.75em;"${tsAttr}>${timeStr}</span>
</div> </div>
<div class="comment-content"><div class="comment-content-inner">${displayContent}${attachmentsHtml}</div><button class="read-more-btn">${window.f0ckI18n?.sidebar_read_more || 'read more'}</button></div> <div class="comment-content"><div class="comment-content-inner">${displayContent}${attachmentsHtml}${pollHtml}</div><button class="read-more-btn">${window.f0ckI18n?.sidebar_read_more || 'read more'}</button></div>
${itemPreview} ${itemPreview}
</div> </div>
</div>`; </div>`;
}; };
const checkOverflow = () => { const checkOverflow = () => {
document.querySelectorAll('.sidebar-activity .comment-content-inner').forEach(inner => { document.querySelectorAll('.sidebar-activity .comment-content-inner').forEach(inner => {
const container = inner.parentElement; const container = inner.parentElement;

View File

@@ -334,7 +334,9 @@
"poll_votes": "Stimmen", "poll_votes": "Stimmen",
"poll_vote_single": "Stimme", "poll_vote_single": "Stimme",
"poll_delete": "Umfrage löschen", "poll_delete": "Umfrage löschen",
"poll_expired": "Umfrage geschlossen" "poll_expired": "Umfrage geschlossen",
"poll_anonymous": "Anonym",
"poll_public": "Öffentliche Stimmen"
}, },
"upload_btn": { "upload_btn": {
"select_file": "Datei auswählen", "select_file": "Datei auswählen",

View File

@@ -334,7 +334,9 @@
"poll_votes": "votes", "poll_votes": "votes",
"poll_vote_single": "vote", "poll_vote_single": "vote",
"poll_delete": "Delete poll", "poll_delete": "Delete poll",
"poll_expired": "Poll closed" "poll_expired": "Poll closed",
"poll_anonymous": "Anonymous",
"poll_public": "Public votes"
}, },
"upload_btn": { "upload_btn": {
"select_file": "Select a file", "select_file": "Select a file",

View File

@@ -334,7 +334,9 @@
"poll_votes": "stemmen", "poll_votes": "stemmen",
"poll_vote_single": "stem", "poll_vote_single": "stem",
"poll_delete": "Peiling verwijderen", "poll_delete": "Peiling verwijderen",
"poll_expired": "Peiling gesloten" "poll_expired": "Peiling gesloten",
"poll_anonymous": "Anoniem",
"poll_public": "Openbare stemmen"
}, },
"upload_btn": { "upload_btn": {
"select_file": "Selecteer een bestand", "select_file": "Selecteer een bestand",

View File

@@ -332,7 +332,9 @@
"poll_votes": "Stimmen", "poll_votes": "Stimmen",
"poll_vote_single": "Stimme", "poll_vote_single": "Stimme",
"poll_delete": "Umfrage löschen", "poll_delete": "Umfrage löschen",
"poll_expired": "Umfrage geschlossen" "poll_expired": "Umfrage geschlossen",
"poll_anonymous": "Anonym",
"poll_public": "Öffentliche Stimmen"
}, },
"upload_btn": { "upload_btn": {
"select_file": "Datei auswählen", "select_file": "Datei auswählen",

View File

@@ -1020,6 +1020,7 @@ export default {
cp.comment_id, cp.comment_id,
cp.question, cp.question,
cp.expires_at, cp.expires_at,
COALESCE(cp.is_anonymous, true) as is_anonymous,
json_agg( json_agg(
json_build_object( json_build_object(
'id', cpo.id, 'id', cpo.id,
@@ -1037,24 +1038,44 @@ export default {
GROUP BY option_id GROUP BY option_id
) vote_counts ON vote_counts.option_id = cpo.id ) vote_counts ON vote_counts.option_id = cpo.id
WHERE cp.comment_id = ANY(${commentIds}::int[]) WHERE cp.comment_id = ANY(${commentIds}::int[])
GROUP BY cp.id, cp.comment_id, cp.question, cp.expires_at 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(); const pollMap = new Map();
for (const p of pollRows) { 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, { pollMap.set(p.comment_id, {
id: p.poll_id, id: p.poll_id,
question: p.question, question: p.question,
expires_at: p.expires_at, expires_at: p.expires_at,
options: p.options, is_anonymous: p.is_anonymous,
options,
total_votes: parseInt(p.total_votes) || 0, total_votes: parseInt(p.total_votes) || 0,
user_vote_option_id: null // filled in per-request context if needed user_vote_option_id: null
}); });
} }
for (const c of comments) { for (const c of comments) {
c.poll = pollMap.get(c.id) || null; c.poll = pollMap.get(c.id) || null;
} }
} catch (e) { } catch (e) {
// Poll tables might not exist yet console.error('[POLLS] getComments poll fetch error:', e.message, e.code);
for (const c of comments) c.poll = null; for (const c of comments) c.poll = null;
} }
} }

View File

@@ -583,7 +583,9 @@
poll_votes: "{{ t('comments.poll_votes') || 'votes' }}", poll_votes: "{{ t('comments.poll_votes') || 'votes' }}",
poll_vote_single: "{{ t('comments.poll_vote_single') || 'vote' }}", poll_vote_single: "{{ t('comments.poll_vote_single') || 'vote' }}",
poll_delete: "{{ t('comments.poll_delete') || 'Delete poll' }}", poll_delete: "{{ t('comments.poll_delete') || 'Delete poll' }}",
poll_expired: "{{ t('comments.poll_expired') || 'Poll closed' }}" poll_expired: "{{ t('comments.poll_expired') || 'Poll closed' }}",
poll_anonymous: "{{ t('comments.poll_anonymous') || 'Anonymous' }}",
poll_public: "{{ t('comments.poll_public') || 'Public votes' }}"
}; };
</script> </script>
<script src="/s/js/f0ckm.js?v={{ ts }}"></script> <script src="/s/js/f0ckm.js?v={{ ts }}"></script>