add polls

This commit is contained in:
2026-05-29 20:15:00 +02:00
parent 9365cb21c8
commit 754fc95d56
12 changed files with 804 additions and 9 deletions

View File

@@ -99,6 +99,7 @@
"show_mime_picker": true, "show_mime_picker": true,
"embed_youtube_in_comments": true, "embed_youtube_in_comments": true,
"allow_comment_deletion": false, "allow_comment_deletion": false,
"enable_comment_polls": false,
"show_content_warning": true, "show_content_warning": true,
"default_comment_display_mode": 1, "default_comment_display_mode": 1,
"phrases": [ "phrases": [

View File

@@ -2842,4 +2842,35 @@ CREATE TABLE IF NOT EXISTS public.wordfilter (
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
); );
-- Comment Polls
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,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE DEFAULT NULL,
UNIQUE(comment_id)
);
CREATE TABLE IF NOT EXISTS public.comment_poll_options (
id SERIAL PRIMARY KEY,
poll_id INTEGER NOT NULL REFERENCES public.comment_polls(id) ON DELETE CASCADE,
option_text TEXT NOT NULL,
display_order SMALLINT NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS public.comment_poll_votes (
id SERIAL PRIMARY KEY,
poll_id INTEGER NOT NULL REFERENCES public.comment_polls(id) ON DELETE CASCADE,
option_id INTEGER NOT NULL REFERENCES public.comment_poll_options(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES public."user"(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(poll_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_comment_polls_comment_id ON public.comment_polls(comment_id);
CREATE INDEX IF NOT EXISTS idx_comment_poll_options_poll ON public.comment_poll_options(poll_id);
CREATE INDEX IF NOT EXISTS idx_comment_poll_votes_poll ON public.comment_poll_votes(poll_id);
CREATE INDEX IF NOT EXISTS idx_comment_poll_votes_user ON public.comment_poll_votes(user_id);
\unrestrict RMNKNzVQLV2ZcwmM3bmhglTot5nRoju9FmRyi3eUMfNy6iJUBfHRIgXnbrpJikG \unrestrict RMNKNzVQLV2ZcwmM3bmhglTot5nRoju9FmRyi3eUMfNy6iJUBfHRIgXnbrpJikG

View File

@@ -2606,6 +2606,223 @@ body.layout-legacy #comments-container.faded-out {
max-width: 350px; max-width: 350px;
} }
/* ─── Poll Button ─────────────────────────────────────────────────────────── */
.comment-poll-btn {
background: none;
border: none;
color: var(--white);
cursor: pointer;
padding: 4px 8px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.15s;
font-size: 14px;
opacity: 0.7;
}
.comment-poll-btn:hover,
.comment-poll-btn.active {
opacity: 1;
color: var(--accent);
}
/* ─── Poll Builder ────────────────────────────────────────────────────────── */
.poll-builder {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
border-bottom: none;
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
animation: pollBuilderIn 0.15s ease;
}
@keyframes pollBuilderIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.poll-question-input,
.poll-option-input {
width: 100%;
background: rgba(0,0,0,0.2);
border: 1px solid rgba(255,255,255,0.1);
color: var(--white);
padding: 5px 8px;
font-family: var(--font, inherit);
font-size: 0.85em;
box-sizing: border-box;
}
.poll-question-input:focus,
.poll-option-input:focus {
outline: none;
border-color: var(--accent);
}
.poll-question-input {
font-weight: 600;
}
.poll-options-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.poll-builder-actions {
display: flex;
gap: 6px;
align-items: center;
}
.poll-add-option-btn,
.poll-remove-btn {
background: none;
border: 1px solid rgba(255,255,255,0.15);
color: #aaa;
cursor: pointer;
padding: 3px 10px;
font-size: 0.8em;
display: inline-flex;
align-items: center;
gap: 4px;
transition: color 0.15s, border-color 0.15s;
}
.poll-add-option-btn:hover {
color: var(--accent);
border-color: var(--accent);
}
.poll-remove-btn:hover {
color: #ff4444;
border-color: #ff4444;
}
/* ─── Poll Widget (rendered in comments) ─────────────────────────────────── */
.comment-poll {
margin-top: 8px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.09);
padding: 10px;
border-radius: 0;
}
.poll-question {
font-weight: 600;
font-size: 0.9em;
margin-bottom: 8px;
color: var(--white);
}
.poll-options {
display: flex;
flex-direction: column;
gap: 5px;
margin-bottom: 8px;
}
.poll-option {
position: relative;
overflow: hidden;
display: flex;
align-items: center;
gap: 6px;
padding: 5px 8px;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.07);
user-select: none;
min-height: 30px;
transition: border-color 0.15s;
}
.poll-option-clickable {
cursor: pointer;
}
.poll-option-clickable:hover {
border-color: rgba(255,255,255,0.18);
background: rgba(255,255,255,0.07);
}
.poll-option-bar {
position: absolute;
left: 0;
top: 0;
bottom: 0;
background: var(--accent);
opacity: 0.18;
transition: width 0.4s ease;
pointer-events: none;
}
.poll-option-voted .poll-option-bar {
opacity: 0.28;
}
.poll-option-text {
position: relative;
flex: 1;
font-size: 0.85em;
color: var(--white);
}
.poll-option-pct {
position: relative;
font-size: 0.78em;
color: #888;
font-family: monospace;
white-space: nowrap;
}
.poll-vote-check {
position: relative;
color: var(--accent);
font-size: 0.75em;
flex-shrink: 0;
}
.poll-footer {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.78em;
color: #666;
}
.poll-total {
font-family: monospace;
}
.poll-expired-badge {
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.1);
padding: 1px 6px;
font-size: 0.85em;
color: #888;
}
.poll-delete-btn {
background: none;
border: none;
color: #555;
cursor: pointer;
padding: 0;
font-size: 0.85em;
margin-left: auto;
transition: color 0.15s;
}
.poll-delete-btn:hover {
color: #ff4444;
}
.comments-list { .comments-list {
display: flex; display: flex;

View File

@@ -2047,6 +2047,7 @@ class CommentSystem {
</div> </div>
<div class="comment-content" data-raw="${this.escapeHtml(comment.content)}">${content}</div> <div class="comment-content" data-raw="${this.escapeHtml(comment.content)}">${content}</div>
${this.renderCommentAttachments(comment.files, comment.content)} ${this.renderCommentAttachments(comment.files, comment.content)}
${this.renderCommentPoll(comment.poll, comment.id, comment.username)}
<div class="comment-footer"> <div class="comment-footer">
<div class="comment-footer-right"> <div class="comment-footer-right">
<div class="comment-actions"> <div class="comment-actions">
@@ -2108,6 +2109,44 @@ class CommentSystem {
return items ? `<div class="comment-attachments">${items}</div>` : ''; return items ? `<div class="comment-attachments">${items}</div>` : '';
} }
renderCommentPoll(poll, commentId, commentUsername) {
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 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);
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>` : ''}
</div>`;
}).join('');
const deleteBtn = canDelete
? `<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}">
<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>
${expired ? `<span class="poll-expired-badge">${i18n.poll_expired || 'Poll closed'}</span>` : ''}
${deleteBtn}
</div>
</div>`;
}
renderInput(parentId = null) { renderInput(parentId = null) {
const i18n = window.f0ckI18n || {}; const i18n = window.f0ckI18n || {};
const session = window.f0ckSession || {}; const session = window.f0ckSession || {};
@@ -2115,6 +2154,7 @@ class CommentSystem {
const postLabel = i18n.post || 'Post'; const postLabel = i18n.post || 'Post';
const cancelLabel = i18n.cancel || 'Cancel'; const cancelLabel = i18n.cancel || 'Cancel';
const attachLabel = i18n.attach_file || 'Attach file'; const attachLabel = i18n.attach_file || 'Attach file';
const pollLabel = i18n.poll_btn_title || 'Create poll';
const maxLen = session.comment_max_length; const maxLen = session.comment_max_length;
const maxLenAttr = (maxLen !== null && maxLen !== undefined) ? ` maxlength="${maxLen}"` : ''; const maxLenAttr = (maxLen !== null && maxLen !== undefined) ? ` maxlength="${maxLen}"` : '';
const counter = (maxLen !== null && maxLen !== undefined) const counter = (maxLen !== null && maxLen !== undefined)
@@ -2125,6 +2165,10 @@ class CommentSystem {
const attachBtn = fileUploadEnabled const attachBtn = fileUploadEnabled
? `<button class="comment-attach-btn" title="${attachLabel}" type="button"><i class="fa-solid fa-paperclip"></i></button><input type="file" class="comment-file-input" accept="image/*,video/*,audio/*" ${multiFile ? 'multiple' : ''} style="display:none;">` ? `<button class="comment-attach-btn" title="${attachLabel}" type="button"><i class="fa-solid fa-paperclip"></i></button><input type="file" class="comment-file-input" accept="image/*,video/*,audio/*" ${multiFile ? 'multiple' : ''} style="display:none;">`
: ''; : '';
const pollEnabled = session.logged_in && session.enable_comment_polls;
const pollBtn = pollEnabled && !parentId
? `<button class="comment-poll-btn" title="${pollLabel}" type="button"><i class="fa-solid fa-chart-bar"></i></button>`
: '';
return ` return `
<div class="comment-input ${parentId ? 'reply-input' : 'main-input'}" ${parentId ? `data-parent="${parentId}"` : ''}> <div class="comment-input ${parentId ? 'reply-input' : 'main-input'}" ${parentId ? `data-parent="${parentId}"` : ''}>
<textarea placeholder="${placeholder}"${maxLenAttr}></textarea> <textarea placeholder="${placeholder}"${maxLenAttr}></textarea>
@@ -2132,6 +2176,7 @@ class CommentSystem {
<div class="input-actions"> <div class="input-actions">
${counter} ${counter}
${attachBtn} ${attachBtn}
${pollBtn}
${parentId ? `<button class="cancel-reply" title="${cancelLabel}"><i class="fa-solid fa-xmark"></i></button>` : ''} ${parentId ? `<button class="cancel-reply" title="${cancelLabel}"><i class="fa-solid fa-xmark"></i></button>` : ''}
<button class="submit-comment"><span class="submit-label">${postLabel}</span><i class="fa-solid fa-spinner fa-spin submit-spinner"></i></button> <button class="submit-comment"><span class="submit-label">${postLabel}</span><i class="fa-solid fa-spinner fa-spin submit-spinner"></i></button>
</div> </div>
@@ -2566,6 +2611,138 @@ class CommentSystem {
return; return;
} }
// Poll button — toggle poll builder
if (target.closest('.comment-poll-btn')) {
const btn = target.closest('.comment-poll-btn');
const wrap = btn.closest('.comment-input');
if (!wrap) return;
const existing = wrap.querySelector('.poll-builder');
if (existing) {
existing.remove();
btn.classList.remove('active');
return;
}
const i18n = window.f0ckI18n || {};
const builder = document.createElement('div');
builder.className = 'poll-builder';
builder.innerHTML = `
<input class="poll-question-input" type="text" placeholder="${i18n.poll_question_placeholder || 'Poll question...'}" maxlength="200">
<div class="poll-options-list">
<input class="poll-option-input" type="text" placeholder="${i18n.poll_option_placeholder || 'Option...'}" maxlength="100">
<input class="poll-option-input" type="text" placeholder="${i18n.poll_option_placeholder || 'Option...'}" maxlength="100">
</div>
<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-remove-btn" type="button"><i class="fa-solid fa-xmark"></i> ${i18n.poll_remove || 'Remove poll'}</button>
</div>
`;
// Insert before input-actions
const actions = wrap.querySelector('.input-actions');
if (actions) wrap.insertBefore(builder, actions);
else wrap.appendChild(builder);
btn.classList.add('active');
builder.querySelector('.poll-question-input').focus();
return;
}
// Poll builder — add option
if (target.closest('.poll-add-option-btn')) {
const list = target.closest('.poll-builder')?.querySelector('.poll-options-list');
if (!list) return;
if (list.querySelectorAll('.poll-option-input').length >= 10) return;
const i18n = window.f0ckI18n || {};
const inp = document.createElement('input');
inp.className = 'poll-option-input';
inp.type = 'text';
inp.placeholder = i18n.poll_option_placeholder || 'Option...';
inp.maxLength = 100;
list.appendChild(inp);
inp.focus();
return;
}
// Poll builder — remove
if (target.closest('.poll-remove-btn')) {
const builder = target.closest('.poll-builder');
if (!builder) return;
const wrap = builder.closest('.comment-input');
if (wrap) wrap.querySelector('.comment-poll-btn')?.classList.remove('active');
builder.remove();
return;
}
// Poll option — vote
if (target.closest('.poll-option-clickable')) {
const opt = target.closest('.poll-option-clickable');
const pollId = opt.dataset.pollId;
const optionId = opt.dataset.optionId;
const commentId = opt.dataset.commentId;
if (!pollId || !optionId) return;
const pollWidget = opt.closest('.comment-poll');
// Disable all options immediately
pollWidget?.querySelectorAll('.poll-option-clickable').forEach(o => o.classList.remove('poll-option-clickable'));
fetch(`/api/polls/${pollId}/vote`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-Token': window.f0ckSession?.csrf_token
},
body: `option_id=${optionId}`
}).then(r => r.json()).then(data => {
if (!data.success) {
if (window.flashMessage) window.flashMessage(data.message || 'Vote failed', 2500, 'error');
return;
}
// Patch poll widget in-place
if (!pollWidget) return;
const i18n = window.f0ckI18n || {};
const total = data.total_votes || 0;
pollWidget.querySelector('.poll-total').textContent = `${total} ${total === 1 ? (i18n.poll_vote_single || 'vote') : (i18n.poll_votes || 'votes')}`;
(data.options || []).forEach(updated => {
const el = pollWidget.querySelector(`.poll-option[data-option-id="${updated.id}"]`);
if (!el) return;
const pct = total > 0 ? Math.round((updated.vote_count / total) * 100) : 0;
el.querySelector('.poll-option-bar').style.width = pct + '%';
el.querySelector('.poll-option-pct').textContent = pct + '%';
if (updated.id === data.user_vote_option_id) {
el.classList.add('poll-option-voted');
if (!el.querySelector('.poll-vote-check')) {
el.insertAdjacentHTML('beforeend', `<i class="fa-solid fa-check poll-vote-check" title="${i18n.poll_voted || 'You voted'}"></i>`);
}
}
});
}).catch(() => {
if (window.flashMessage) window.flashMessage('Network error', 2500, 'error');
});
return;
}
// Poll delete button
if (target.closest('.poll-delete-btn')) {
const btn = target.closest('.poll-delete-btn');
const pollId = btn.dataset.pollId;
if (!pollId) return;
if (!confirm('Delete this poll?')) return;
fetch(`/api/polls/${pollId}/delete`, {
method: 'POST',
headers: { 'X-CSRF-Token': window.f0ckSession?.csrf_token }
}).then(r => r.json()).then(data => {
if (data.success) {
const widget = btn.closest('.comment-poll');
if (widget) {
widget.style.transition = 'opacity 0.3s';
widget.style.opacity = '0';
setTimeout(() => widget.remove(), 300);
}
} else {
if (window.flashMessage) window.flashMessage(data.message || 'Delete failed', 2500, 'error');
}
}).catch(() => {
if (window.flashMessage) window.flashMessage('Network error', 2500, 'error');
});
return;
}
// Submit Comment // Submit Comment
if (target.closest('.submit-comment')) { if (target.closest('.submit-comment')) {
this.handleSubmit(e); this.handleSubmit(e);
@@ -2965,7 +3142,20 @@ class CommentSystem {
}); });
} }
if (!text && fileIds.length === 0) return; // Collect poll data from builder (if present)
let pollPayload = null;
const pollBuilder = wrap.querySelector('.poll-builder');
if (pollBuilder) {
const question = pollBuilder.querySelector('.poll-question-input')?.value.trim() || '';
const options = [...pollBuilder.querySelectorAll('.poll-option-input')]
.map(i => i.value.trim())
.filter(Boolean);
if (question && options.length >= 2) {
pollPayload = { question, options };
}
}
if (!text && fileIds.length === 0 && !pollPayload) return;
if (submitBtn.classList.contains('loading') || submitBtn.disabled) return; if (submitBtn.classList.contains('loading') || submitBtn.disabled) return;
if (wrap._pendingUploads > 0) return; if (wrap._pendingUploads > 0) return;
@@ -3040,6 +3230,36 @@ class CommentSystem {
} }
const fpArea = wrap.querySelector('.comment-file-preview'); const fpArea = wrap.querySelector('.comment-file-preview');
if (fpArea) fpArea.innerHTML = ''; if (fpArea) fpArea.innerHTML = '';
// Remove poll builder after posting
wrap.querySelector('.poll-builder')?.remove();
wrap.querySelector('.comment-poll-btn')?.classList.remove('active');
}
// If there was a poll, attach it now
const commentId = json.comment?.id;
if (pollPayload && commentId) {
const pfd = new FormData();
pfd.append('poll', JSON.stringify(pollPayload));
fetch(`/api/polls/attach/${commentId}`, {
method: 'POST',
headers: { 'X-CSRF-Token': window.f0ckSession?.csrf_token },
body: new URLSearchParams(pfd)
}).then(r => r.json()).then(pData => {
if (pData.success && pData.poll) {
// Patch poll into newComment and update DOM
newComment.poll = pData.poll;
const commentEl = document.getElementById('c' + commentId);
if (commentEl) {
const attachmentsEl = commentEl.querySelector('.comment-attachments');
const contentEl = commentEl.querySelector('.comment-content');
const insertAfter = attachmentsEl || contentEl;
if (insertAfter) {
const pollHtml = this.renderCommentPoll(pData.poll, commentId, window.f0ckSession?.user);
insertAfter.insertAdjacentHTML('afterend', pollHtml);
}
}
}
}).catch(() => {});
} }
// Notify the right sidebar that a new comment was posted (silent refresh) // Notify the right sidebar that a new comment was posted (silent refresh)
@@ -3097,7 +3317,8 @@ class CommentSystem {
is_pinned: false, is_pinned: false,
video_time: json.comment.video_time ?? null, video_time: json.comment.video_time ?? null,
replies: [], replies: [],
replyTo: null replyTo: null,
poll: null
}; };
// Danmaku: fire immediately (one-shot) + add to future rotation // Danmaku: fire immediately (one-shot) + add to future rotation

View File

@@ -323,7 +323,18 @@
"attach_file": "Datei anhängen", "attach_file": "Datei anhängen",
"uploading_file": "Wird hochgeladen...", "uploading_file": "Wird hochgeladen...",
"remove_file": "Datei entfernen", "remove_file": "Datei entfernen",
"file_too_large": "Datei zu groß" "file_too_large": "Datei zu groß",
"poll_btn_title": "Umfrage erstellen",
"poll_question_placeholder": "Umfragefrage...",
"poll_option_placeholder": "Option...",
"poll_add_option": "Option hinzufügen",
"poll_remove": "Umfrage entfernen",
"poll_vote": "Abstimmen",
"poll_voted": "Abgestimmt",
"poll_votes": "Stimmen",
"poll_vote_single": "Stimme",
"poll_delete": "Umfrage löschen",
"poll_expired": "Umfrage geschlossen"
}, },
"upload_btn": { "upload_btn": {
"select_file": "Datei auswählen", "select_file": "Datei auswählen",

View File

@@ -323,7 +323,18 @@
"attach_file": "Attach file", "attach_file": "Attach file",
"uploading_file": "Uploading...", "uploading_file": "Uploading...",
"remove_file": "Remove file", "remove_file": "Remove file",
"file_too_large": "File too large" "file_too_large": "File too large",
"poll_btn_title": "Create poll",
"poll_question_placeholder": "Poll question...",
"poll_option_placeholder": "Option...",
"poll_add_option": "Add option",
"poll_remove": "Remove poll",
"poll_vote": "Vote",
"poll_voted": "You voted",
"poll_votes": "votes",
"poll_vote_single": "vote",
"poll_delete": "Delete poll",
"poll_expired": "Poll closed"
}, },
"upload_btn": { "upload_btn": {
"select_file": "Select a file", "select_file": "Select a file",

View File

@@ -323,7 +323,18 @@
"attach_file": "Bestand bijvoegen", "attach_file": "Bestand bijvoegen",
"uploading_file": "Uploaden...", "uploading_file": "Uploaden...",
"remove_file": "Bestand verwijderen", "remove_file": "Bestand verwijderen",
"file_too_large": "Bestand te groot" "file_too_large": "Bestand te groot",
"poll_btn_title": "Peiling aanmaken",
"poll_question_placeholder": "Peilingvraag...",
"poll_option_placeholder": "Optie...",
"poll_add_option": "Optie toevoegen",
"poll_remove": "Peiling verwijderen",
"poll_vote": "Stemmen",
"poll_voted": "Je hebt gestemd",
"poll_votes": "stemmen",
"poll_vote_single": "stem",
"poll_delete": "Peiling verwijderen",
"poll_expired": "Peiling gesloten"
}, },
"upload_btn": { "upload_btn": {
"select_file": "Selecteer een bestand", "select_file": "Selecteer een bestand",

View File

@@ -321,7 +321,18 @@
"attach_file": "Datei anflanschen", "attach_file": "Datei anflanschen",
"uploading_file": "Wird aufladiert...", "uploading_file": "Wird aufladiert...",
"remove_file": "Datei entfernen", "remove_file": "Datei entfernen",
"file_too_large": "Datei zu voluminös" "file_too_large": "Datei zu voluminös",
"poll_btn_title": "Umfrage erstellen",
"poll_question_placeholder": "Frage",
"poll_option_placeholder": "Option",
"poll_add_option": "Option hinzufügen",
"poll_remove": "Umfrage entfernen",
"poll_vote": "Abstimmen",
"poll_voted": "Abgestimmt",
"poll_votes": "Stimmen",
"poll_vote_single": "Stimme",
"poll_delete": "Umfrage löschen",
"poll_expired": "Umfrage geschlossen"
}, },
"upload_btn": { "upload_btn": {
"select_file": "Datei auswählen", "select_file": "Datei auswählen",

View File

@@ -1011,6 +1011,52 @@ export default {
// Table might not exist yet, gracefully degrade // Table might not exist yet, gracefully degrade
for (const c of comments) c.files = []; for (const c of comments) c.files = [];
} }
// Fetch poll data for comments that have one
try {
const pollRows = await db`
SELECT
cp.id as poll_id,
cp.comment_id,
cp.question,
cp.expires_at,
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
`;
const pollMap = new Map();
for (const p of pollRows) {
pollMap.set(p.comment_id, {
id: p.poll_id,
question: p.question,
expires_at: p.expires_at,
options: p.options,
total_votes: parseInt(p.total_votes) || 0,
user_vote_option_id: null // filled in per-request context if needed
});
}
for (const c of comments) {
c.poll = pollMap.get(c.id) || null;
}
} catch (e) {
// Poll tables might not exist yet
for (const c of comments) c.poll = null;
}
} }
console.log(`[${new Date().toISOString()}] [GETCOMMENTS] Fetched ${comments.length} comments for item ${itemId} in ${Date.now() - tStart}ms`); console.log(`[${new Date().toISOString()}] [GETCOMMENTS] Fetched ${comments.length} comments for item ${itemId} in ${Date.now() - tStart}ms`);

View File

@@ -43,6 +43,24 @@ export default (router, tpl) => {
if (sub.length > 0) is_subscribed = true; if (sub.length > 0) is_subscribed = true;
} }
// Fill in per-user poll votes
if (req.session && cfg.websrv.enable_comment_polls) {
const pollComments = comments.filter(c => c.poll);
if (pollComments.length > 0) {
const pollIds = pollComments.map(c => c.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 c of pollComments) {
if (c.poll) c.poll.user_vote_option_id = voteMap.get(c.poll.id) || null;
}
} catch (e) { /* graceful */ }
}
}
// 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; charset=utf-8' }, headers: { 'Content-Type': 'application/json; charset=utf-8' },
@@ -271,7 +289,9 @@ export default (router, tpl) => {
const fileIdsRaw = body.file_ids || ''; const fileIdsRaw = body.file_ids || '';
const fileIds = fileIdsRaw ? fileIdsRaw.split(',').map(id => parseInt(id, 10)).filter(id => !isNaN(id) && id > 0) : []; const fileIds = fileIdsRaw ? fileIdsRaw.split(',').map(id => parseInt(id, 10)).filter(id => !isNaN(id) && id > 0) : [];
if ((!content || !content.trim()) && fileIds.length === 0) { const hasPoll = body.has_poll === '1' || body.has_poll === 'true';
if ((!content || !content.trim()) && fileIds.length === 0 && !hasPoll) {
return res.reply({ body: JSON.stringify({ success: false, message: "Empty comment" }) }); return res.reply({ body: JSON.stringify({ success: false, message: "Empty comment" }) });
} }
@@ -983,5 +1003,206 @@ export default (router, tpl) => {
} }
}); });
// ──────────────────────────────────────────────────────────────────────────
// Poll creation — called after a comment is inserted (internal helper)
// ──────────────────────────────────────────────────────────────────────────
const createPollForComment = async (commentId, pollData) => {
if (!cfg.websrv.enable_comment_polls) return null;
const { question, options } = pollData || {};
if (!question || !question.trim()) return null;
if (!Array.isArray(options) || options.length < 2) return null;
const cleanOptions = options.map(o => (typeof o === 'string' ? o : String(o)).trim()).filter(Boolean);
if (cleanOptions.length < 2 || cleanOptions.length > 10) return null;
const [poll] = await db`
INSERT INTO comment_polls (comment_id, question)
VALUES (${commentId}, ${question.trim()})
RETURNING id
`;
const pollId = poll.id;
for (let i = 0; i < cleanOptions.length; i++) {
await db`
INSERT INTO comment_poll_options (poll_id, text, sort_order)
VALUES (${pollId}, ${cleanOptions[i]}, ${i})
`;
}
const optRows = await db`SELECT id, text, sort_order FROM comment_poll_options WHERE poll_id = ${pollId} ORDER BY sort_order ASC`;
return {
id: pollId,
question: question.trim(),
options: optRows.map(o => ({ id: o.id, text: o.text, sort_order: o.sort_order, vote_count: 0 })),
total_votes: 0,
user_vote_option_id: null
};
};
// Patch POST /api/comments to support optional poll payload
// We cannot re-define the same route, so we intercept via a pre-middleware trick.
// Instead we add a dedicated endpoint that the frontend always uses for polls.
// POST /api/polls/:commentId — attach a poll to an existing just-created comment
// (frontend calls this immediately after posting the comment)
router.post(/\/api\/polls\/attach\/(?<commentId>\d+)/, async (req, res) => {
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
if (!cfg.websrv.enable_comment_polls) return res.reply({ code: 403, body: JSON.stringify({ success: false, message: 'Polls disabled' }) });
const commentId = req.params.commentId;
const body = req.post || {};
// Verify this comment belongs to the logged-in user and has no poll yet
const comment = await db`SELECT id, user_id FROM comments WHERE id = ${commentId} AND is_deleted = false LIMIT 1`;
if (!comment.length) return res.reply({ code: 404, body: JSON.stringify({ success: false, message: 'Comment not found' }) });
if (comment[0].user_id !== req.session.id && !req.session.admin && !req.session.is_moderator) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: 'Forbidden' }) });
}
const existing = await db`SELECT id FROM comment_polls WHERE comment_id = ${commentId} LIMIT 1`;
if (existing.length) return res.reply({ code: 409, body: JSON.stringify({ success: false, message: 'Poll already exists' }) });
let pollData;
try {
pollData = typeof body.poll === 'string' ? JSON.parse(body.poll) : body.poll;
} catch (e) {
return res.reply({ code: 400, body: JSON.stringify({ success: false, message: 'Invalid poll JSON' }) });
}
try {
const poll = await createPollForComment(parseInt(commentId, 10), pollData);
if (!poll) return res.reply({ code: 400, body: JSON.stringify({ success: false, message: 'Invalid poll data (need question + 2-10 options)' }) });
return res.reply({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: true, poll }) });
} catch (err) {
console.error('[POLLS] createPollForComment error:', err);
return res.reply({ code: 500, body: JSON.stringify({ success: false, message: 'Database error' }) });
}
});
// GET /api/polls/:pollId — fetch poll with current user's vote
router.get(/\/api\/polls\/(?<pollId>\d+)/, async (req, res) => {
if (!cfg.websrv.enable_comment_polls) return res.reply({ code: 403, body: JSON.stringify({ success: false }) });
const pollId = req.params.pollId;
try {
const pollRows = await db`
SELECT
cp.id as poll_id, cp.comment_id, cp.question, cp.expires_at,
json_agg(
json_build_object(
'id', cpo.id, 'text', cpo.text, 'sort_order', cpo.sort_order,
'vote_count', COALESCE(vc.cnt, 0)
) ORDER BY cpo.sort_order ASC, cpo.id ASC
) AS options,
COALESCE(SUM(vc.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) vc ON vc.option_id = cpo.id
WHERE cp.id = ${pollId}
GROUP BY cp.id, cp.comment_id, cp.question, cp.expires_at
`;
if (!pollRows.length) return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
const p = pollRows[0];
let userVoteOptionId = null;
if (req.session) {
const vote = await db`SELECT option_id FROM comment_poll_votes WHERE poll_id = ${pollId} AND user_id = ${req.session.id} LIMIT 1`;
if (vote.length) userVoteOptionId = vote[0].option_id;
}
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
success: true,
poll: {
id: p.poll_id,
comment_id: p.comment_id,
question: p.question,
expires_at: p.expires_at,
options: p.options,
total_votes: parseInt(p.total_votes) || 0,
user_vote_option_id: userVoteOptionId
}
})
});
} catch (err) {
console.error('[POLLS] GET error:', err);
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
}
});
// POST /api/polls/:pollId/vote — cast or change vote
router.post(/\/api\/polls\/(?<pollId>\d+)\/vote/, async (req, res) => {
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
if (!cfg.websrv.enable_comment_polls) return res.reply({ code: 403, body: JSON.stringify({ success: false }) });
const pollId = req.params.pollId;
const body = req.post || {};
const optionId = parseInt(body.option_id, 10);
if (!optionId) return res.reply({ code: 400, body: JSON.stringify({ success: false, message: 'Missing option_id' }) });
try {
// Verify option belongs to poll
const opt = await db`SELECT id FROM comment_poll_options WHERE id = ${optionId} AND poll_id = ${pollId} LIMIT 1`;
if (!opt.length) return res.reply({ code: 400, body: JSON.stringify({ success: false, message: 'Invalid option' }) });
// Check expiry
const poll = await db`SELECT expires_at FROM comment_polls WHERE id = ${pollId} LIMIT 1`;
if (!poll.length) return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
if (poll[0].expires_at && new Date(poll[0].expires_at) < new Date()) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: 'Poll has expired' }) });
}
// Upsert vote (change allowed)
await db`
INSERT INTO comment_poll_votes (poll_id, option_id, user_id)
VALUES (${pollId}, ${optionId}, ${req.session.id})
ON CONFLICT (poll_id, user_id) DO UPDATE SET option_id = ${optionId}, created_at = now()
`;
// Return updated tally
const rows = await db`
SELECT cpo.id, cpo.text, cpo.sort_order, COALESCE(vc.cnt, 0)::int AS vote_count
FROM comment_poll_options cpo
LEFT JOIN (SELECT option_id, COUNT(*) AS cnt FROM comment_poll_votes WHERE poll_id = ${pollId} GROUP BY option_id) vc ON vc.option_id = cpo.id
WHERE cpo.poll_id = ${pollId}
ORDER BY cpo.sort_order ASC
`;
const totalVotes = rows.reduce((s, r) => s + r.vote_count, 0);
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: true, options: rows, total_votes: totalVotes, user_vote_option_id: optionId })
});
} catch (err) {
console.error('[POLLS] vote error:', err);
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
}
});
// DELETE /api/polls/:pollId — admin/mod or creator can delete
router.post(/\/api\/polls\/(?<pollId>\d+)\/delete/, async (req, res) => {
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
if (!cfg.websrv.enable_comment_polls) return res.reply({ code: 403, body: JSON.stringify({ success: false }) });
const pollId = req.params.pollId;
try {
const poll = await db`
SELECT cp.id, cp.comment_id, c.user_id
FROM comment_polls cp
JOIN comments c ON c.id = cp.comment_id
WHERE cp.id = ${pollId} LIMIT 1
`;
if (!poll.length) return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
const isCreator = poll[0].user_id === req.session.id;
if (!isCreator && !req.session.admin && !req.session.is_moderator) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: 'Forbidden' }) });
}
await db`DELETE FROM comment_polls WHERE id = ${pollId}`;
// Notify live update
db.notify('comments', JSON.stringify({ type: 'poll_deleted', poll_id: pollId, comment_id: poll[0].comment_id }));
return res.reply({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: true }) });
} catch (err) {
console.error('[POLLS] delete error:', err);
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
}
});
return router; return router;
}; };

View File

@@ -1154,6 +1154,7 @@ process.on('uncaughtException', err => {
fileupload_comments_max: cfg.websrv.fileupload_comments_max || 5, fileupload_comments_max: cfg.websrv.fileupload_comments_max || 5,
fileupload_comments_mode: cfg.websrv.fileupload_comments_mode || 'attachment', fileupload_comments_mode: cfg.websrv.fileupload_comments_mode || 'attachment',
fileupload_comments_mimes: Array.isArray(cfg.websrv.fileupload_comments_mimes) ? cfg.websrv.fileupload_comments_mimes : ['image', 'video', 'audio'], fileupload_comments_mimes: Array.isArray(cfg.websrv.fileupload_comments_mimes) ? cfg.websrv.fileupload_comments_mimes : ['image', 'video', 'audio'],
enable_comment_polls: cfg.websrv.enable_comment_polls || false,
get fonts() { get fonts() {
try { try {

View File

@@ -415,7 +415,8 @@
fileupload_comments_mode: "{{ fileupload_comments_mode }}", fileupload_comments_mode: "{{ fileupload_comments_mode }}",
dm_attachments: @if(dm_attachments) true @else false @endif, dm_attachments: @if(dm_attachments) true @else false @endif,
dm_unencrypted: @if(dm_unencrypted) true @else false @endif, dm_unencrypted: @if(dm_unencrypted) true @else false @endif,
allow_comment_deletion: @if(allow_comment_deletion) true @else false @endif allow_comment_deletion: @if(allow_comment_deletion) true @else false @endif,
enable_comment_polls: @if(enable_comment_polls) true @else false @endif
}; };
window.f0ckDebug = window.f0ckSession.development ? console.log.bind(console) : () => {}; window.f0ckDebug = window.f0ckSession.development ? console.log.bind(console) : () => {};
window.f0ckI18n = { window.f0ckI18n = {
@@ -570,7 +571,19 @@
attach_file: "{{ t('comments.attach_file') }}", attach_file: "{{ t('comments.attach_file') }}",
uploading_file: "{{ t('comments.uploading_file') }}", uploading_file: "{{ t('comments.uploading_file') }}",
remove_file: "{{ t('comments.remove_file') }}", remove_file: "{{ t('comments.remove_file') }}",
file_too_large: "{{ t('comments.file_too_large') }}" file_too_large: "{{ t('comments.file_too_large') }}",
// polls
poll_btn_title: "{{ t('comments.poll_btn_title') || 'Create poll' }}",
poll_question_placeholder: "{{ t('comments.poll_question_placeholder') || 'Poll question...' }}",
poll_option_placeholder: "{{ t('comments.poll_option_placeholder') || 'Option...' }}",
poll_add_option: "{{ t('comments.poll_add_option') || 'Add option' }}",
poll_remove: "{{ t('comments.poll_remove') || 'Remove poll' }}",
poll_vote: "{{ t('comments.poll_vote') || 'Vote' }}",
poll_voted: "{{ t('comments.poll_voted') || 'You voted' }}",
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' }}"
}; };
</script> </script>
<script src="/s/js/f0ckm.js?v={{ ts }}"></script> <script src="/s/js/f0ckm.js?v={{ ts }}"></script>