diff --git a/migrations/f0ckm_schema.sql b/migrations/f0ckm_schema.sql
index cc50336..42a3058 100644
--- a/migrations/f0ckm_schema.sql
+++ b/migrations/f0ckm_schema.sql
@@ -2847,6 +2847,7 @@ CREATE TABLE IF NOT EXISTS public.comment_polls (
id SERIAL PRIMARY KEY,
comment_id INTEGER NOT NULL REFERENCES public.comments(id) ON DELETE CASCADE,
question TEXT NOT NULL,
+ is_anonymous BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE DEFAULT NULL,
UNIQUE(comment_id)
diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css
index 13273da..7f03eb6 100644
--- a/public/s/css/f0ckm.css
+++ b/public/s/css/f0ckm.css
@@ -2823,6 +2823,84 @@ body.layout-legacy #comments-container.faded-out {
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 {
display: flex;
diff --git a/public/s/js/comments.js b/public/s/js/comments.js
index 5c3a6c6..4fc11c4 100644
--- a/public/s/js/comments.js
+++ b/public/s/js/comments.js
@@ -2116,19 +2116,29 @@ class CommentSystem {
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 canDelete = session.logged_in && (session.is_admin || session.is_moderator || session.user === commentUsername);
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 clickable = session.logged_in && !expired && !voted;
+ const voterAvatars = (!isAnon && Array.isArray(opt.voters) && opt.voters.length > 0)
+ ? `
${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 ? `

` : '';
+ }).join('')}
`
+ : '';
return `
${this.escapeHtml(opt.text)}
${pct}%
${isVoted ? `
` : ''}
+ ${voterAvatars}
`;
}).join('');
@@ -2136,11 +2146,16 @@ class CommentSystem {
? ``
: '';
- return `
-
+
${itemPreview}
`;
};
+
const checkOverflow = () => {
document.querySelectorAll('.sidebar-activity .comment-content-inner').forEach(inner => {
const container = inner.parentElement;
diff --git a/src/inc/locales/de.json b/src/inc/locales/de.json
index 3094d6f..a83f382 100644
--- a/src/inc/locales/de.json
+++ b/src/inc/locales/de.json
@@ -334,7 +334,9 @@
"poll_votes": "Stimmen",
"poll_vote_single": "Stimme",
"poll_delete": "Umfrage löschen",
- "poll_expired": "Umfrage geschlossen"
+ "poll_expired": "Umfrage geschlossen",
+ "poll_anonymous": "Anonym",
+ "poll_public": "Öffentliche Stimmen"
},
"upload_btn": {
"select_file": "Datei auswählen",
diff --git a/src/inc/locales/en.json b/src/inc/locales/en.json
index feeebb2..fa5a11b 100644
--- a/src/inc/locales/en.json
+++ b/src/inc/locales/en.json
@@ -334,7 +334,9 @@
"poll_votes": "votes",
"poll_vote_single": "vote",
"poll_delete": "Delete poll",
- "poll_expired": "Poll closed"
+ "poll_expired": "Poll closed",
+ "poll_anonymous": "Anonymous",
+ "poll_public": "Public votes"
},
"upload_btn": {
"select_file": "Select a file",
diff --git a/src/inc/locales/nl.json b/src/inc/locales/nl.json
index 2329e9f..a3a2363 100644
--- a/src/inc/locales/nl.json
+++ b/src/inc/locales/nl.json
@@ -334,7 +334,9 @@
"poll_votes": "stemmen",
"poll_vote_single": "stem",
"poll_delete": "Peiling verwijderen",
- "poll_expired": "Peiling gesloten"
+ "poll_expired": "Peiling gesloten",
+ "poll_anonymous": "Anoniem",
+ "poll_public": "Openbare stemmen"
},
"upload_btn": {
"select_file": "Selecteer een bestand",
diff --git a/src/inc/locales/zange.json b/src/inc/locales/zange.json
index 2f37efe..c55db23 100644
--- a/src/inc/locales/zange.json
+++ b/src/inc/locales/zange.json
@@ -332,7 +332,9 @@
"poll_votes": "Stimmen",
"poll_vote_single": "Stimme",
"poll_delete": "Umfrage löschen",
- "poll_expired": "Umfrage geschlossen"
+ "poll_expired": "Umfrage geschlossen",
+ "poll_anonymous": "Anonym",
+ "poll_public": "Öffentliche Stimmen"
},
"upload_btn": {
"select_file": "Datei auswählen",
diff --git a/src/inc/routeinc/f0cklib.mjs b/src/inc/routeinc/f0cklib.mjs
index d3b84a5..fefac6a 100644
--- a/src/inc/routeinc/f0cklib.mjs
+++ b/src/inc/routeinc/f0cklib.mjs
@@ -1020,6 +1020,7 @@ export default {
cp.comment_id,
cp.question,
cp.expires_at,
+ COALESCE(cp.is_anonymous, true) as is_anonymous,
json_agg(
json_build_object(
'id', cpo.id,
@@ -1037,24 +1038,44 @@ export default {
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
+ 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,
- options: p.options,
+ is_anonymous: p.is_anonymous,
+ options,
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) {
c.poll = pollMap.get(c.id) || null;
}
} 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;
}
}
diff --git a/views/snippets/footer.html b/views/snippets/footer.html
index d32c439..aa82aa6 100644
--- a/views/snippets/footer.html
+++ b/views/snippets/footer.html
@@ -583,7 +583,9 @@
poll_votes: "{{ t('comments.poll_votes') || 'votes' }}",
poll_vote_single: "{{ t('comments.poll_vote_single') || 'vote' }}",
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' }}"
};