nordabiz/templates/admin/forum.html
Maciej Pienczyn c5f724f954 feat: Add forum search, markdown, user stats, and admin bulk actions
New features implemented:
- Forum search with title/content filtering
- Solution filter (topics with marked solutions)
- Quote reply functionality with @mention
- @mentions parsing and notifications
- Simple markdown formatting (bold, italic, code, quotes, lists)
- User forum statistics tooltip (topics, replies, solutions, reactions)
- Admin bulk actions (pin/unpin, lock/unlock, status change, delete)

Files changed:
- blueprints/forum/routes.py: user_forum_stats, admin_forum_bulk_action endpoints
- templates/forum/topic.html: user stats tooltips, markdown CSS
- templates/forum/index.html: search box, solution filter
- templates/admin/forum.html: bulk selection checkboxes and action bar
- utils/markdown.py: simple forum markdown parser
- utils/notifications.py: @mention notification parsing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 19:11:29 +01:00

1005 lines
33 KiB
HTML
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "base.html" %}
{% block title %}Moderacja Forum - Norda Biznes Partner{% endblock %}
{% block extra_css %}
<style>
.admin-header {
margin-bottom: var(--spacing-xl);
}
.admin-header h1 {
font-size: var(--font-size-3xl);
color: var(--text-primary);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: var(--spacing-lg);
margin-bottom: var(--spacing-2xl);
}
.stat-card {
background: var(--surface);
padding: var(--spacing-lg);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
text-align: center;
}
.stat-value {
font-size: var(--font-size-3xl);
font-weight: 700;
color: var(--primary);
}
.stat-label {
color: var(--text-secondary);
font-size: var(--font-size-sm);
margin-top: var(--spacing-xs);
}
.stat-card.category-feature_request .stat-value { color: #1e40af; }
.stat-card.category-bug .stat-value { color: #991b1b; }
.stat-card.category-question .stat-value { color: #166534; }
.stat-card.category-announcement .stat-value { color: #92400e; }
.stat-card.status-new .stat-value { color: #374151; }
.stat-card.status-in_progress .stat-value { color: #1e40af; }
.stat-card.status-resolved .stat-value { color: #166534; }
.stat-card.status-rejected .stat-value { color: #991b1b; }
.section {
background: var(--surface);
padding: var(--spacing-xl);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
margin-bottom: var(--spacing-xl);
}
.section h2 {
font-size: var(--font-size-xl);
margin-bottom: var(--spacing-lg);
color: var(--text-primary);
border-bottom: 2px solid var(--border);
padding-bottom: var(--spacing-sm);
}
.topics-table {
width: 100%;
border-collapse: collapse;
}
.topics-table th,
.topics-table td {
padding: var(--spacing-md);
text-align: left;
border-bottom: 1px solid var(--border);
}
.topics-table th {
font-weight: 600;
color: var(--text-secondary);
font-size: var(--font-size-sm);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.topics-table tr:hover {
background: var(--background);
}
.topic-title {
font-weight: 500;
color: var(--text-primary);
}
.topic-title a {
color: inherit;
text-decoration: none;
}
.topic-title a:hover {
color: var(--primary);
}
.topic-meta {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 500;
}
.badge-pinned {
background: var(--primary);
color: white;
}
.badge-locked {
background: var(--secondary);
color: white;
}
/* Category badges */
.badge-category {
border: 1px solid;
}
.badge-feature_request {
background: #dbeafe;
color: #1e40af;
border-color: #93c5fd;
}
.badge-bug {
background: #fee2e2;
color: #991b1b;
border-color: #fca5a5;
}
.badge-question {
background: #dcfce7;
color: #166534;
border-color: #86efac;
}
.badge-announcement {
background: #fef3c7;
color: #92400e;
border-color: #fcd34d;
}
/* Status badges */
.badge-status {
cursor: pointer;
transition: var(--transition);
}
.badge-status:hover {
opacity: 0.8;
}
.badge-new {
background: #f3f4f6;
color: #374151;
}
.badge-in_progress {
background: #dbeafe;
color: #1e40af;
}
.badge-resolved {
background: #dcfce7;
color: #166534;
}
.badge-rejected {
background: #fee2e2;
color: #991b1b;
}
.action-buttons {
display: flex;
gap: var(--spacing-xs);
}
.btn-icon {
width: 32px;
height: 32px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--surface);
cursor: pointer;
transition: var(--transition);
}
.btn-icon:hover {
background: var(--background);
}
.btn-icon.active {
background: var(--primary);
border-color: var(--primary);
color: white;
}
.btn-icon.danger:hover {
background: var(--error);
border-color: var(--error);
color: white;
}
.btn-icon svg {
width: 16px;
height: 16px;
}
.replies-list {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.reply-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: var(--spacing-md);
background: var(--background);
border-radius: var(--radius);
}
.reply-content {
flex: 1;
font-size: var(--font-size-sm);
color: var(--text-primary);
max-height: 60px;
overflow: hidden;
}
.reply-meta {
font-size: var(--font-size-xs);
color: var(--text-secondary);
margin-top: var(--spacing-xs);
}
.empty-state {
text-align: center;
padding: var(--spacing-2xl);
color: var(--text-secondary);
}
/* Status change modal */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
justify-content: center;
align-items: center;
}
.modal-overlay.active {
display: flex;
}
.modal-content {
background: var(--surface);
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
max-width: 400px;
width: 90%;
box-shadow: var(--shadow-lg);
}
.modal-header {
font-size: var(--font-size-lg);
font-weight: 600;
margin-bottom: var(--spacing-lg);
color: var(--text-primary);
}
.modal-body {
margin-bottom: var(--spacing-lg);
}
.form-group {
margin-bottom: var(--spacing-md);
}
.form-label {
display: block;
font-weight: 500;
margin-bottom: var(--spacing-sm);
color: var(--text-primary);
}
.form-select, .form-input {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: var(--font-size-base);
background: var(--surface);
}
.form-select:focus, .form-input:focus {
outline: none;
border-color: var(--primary);
}
.modal-footer {
display: flex;
gap: var(--spacing-md);
justify-content: flex-end;
}
@media (max-width: 768px) {
.topics-table {
font-size: var(--font-size-sm);
}
.topics-table th:nth-child(3),
.topics-table td:nth-child(3),
.topics-table th:nth-child(5),
.topics-table td:nth-child(5) {
display: none;
}
}
/* Bulk actions */
.bulk-checkbox {
width: 18px;
height: 18px;
cursor: pointer;
}
.bulk-actions-bar {
display: none;
position: sticky;
top: 0;
background: var(--primary);
color: white;
padding: var(--spacing-md) var(--spacing-lg);
border-radius: var(--radius);
margin-bottom: var(--spacing-md);
justify-content: space-between;
align-items: center;
z-index: 100;
box-shadow: var(--shadow);
}
.bulk-actions-bar.visible {
display: flex;
}
.bulk-actions-bar .selected-count {
font-weight: 600;
}
.bulk-actions-bar .bulk-actions {
display: flex;
gap: var(--spacing-sm);
}
.bulk-actions-bar .bulk-btn {
background: rgba(255,255,255,0.2);
border: none;
color: white;
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
cursor: pointer;
font-size: var(--font-size-sm);
transition: var(--transition);
}
.bulk-actions-bar .bulk-btn:hover {
background: rgba(255,255,255,0.3);
}
.bulk-actions-bar .bulk-btn.danger {
background: var(--error);
}
.bulk-actions-bar .bulk-btn.danger:hover {
background: #c53030;
}
.topics-table tr.selected {
background: #dbeafe;
}
</style>
{% endblock %}
{% block content %}
<div class="admin-header">
<h1>Moderacja Forum</h1>
<p class="text-muted">Zarządzaj tematami i odpowiedziami na forum</p>
</div>
<!-- Quick Actions -->
<div style="display: flex; gap: var(--spacing-md); margin-bottom: var(--spacing-xl);">
<a href="{{ url_for('admin_forum_reports') }}" class="btn btn-outline" style="display: inline-flex; align-items: center; gap: var(--spacing-xs);">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9"/></svg>
Zgłoszenia
</a>
<a href="{{ url_for('admin_deleted_content') }}" class="btn btn-outline" style="display: inline-flex; align-items: center; gap: var(--spacing-xs);">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
Usunięte treści
</a>
</div>
<!-- Stats Grid -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{{ total_topics }}</div>
<div class="stat-label">Tematow</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ total_replies }}</div>
<div class="stat-label">Odpowiedzi</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ pinned_count }}</div>
<div class="stat-label">Przypietych</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ locked_count }}</div>
<div class="stat-label">Zamknietych</div>
</div>
</div>
<!-- Status Stats -->
<div class="stats-grid">
<div class="stat-card status-new">
<div class="stat-value">{{ status_counts.get('new', 0) }}</div>
<div class="stat-label">Nowych</div>
</div>
<div class="stat-card status-in_progress">
<div class="stat-value">{{ status_counts.get('in_progress', 0) }}</div>
<div class="stat-label">W realizacji</div>
</div>
<div class="stat-card status-resolved">
<div class="stat-value">{{ status_counts.get('resolved', 0) }}</div>
<div class="stat-label">Rozwiazanych</div>
</div>
<div class="stat-card status-rejected">
<div class="stat-value">{{ status_counts.get('rejected', 0) }}</div>
<div class="stat-label">Odrzuconych</div>
</div>
</div>
<!-- Category Stats -->
<div class="stats-grid">
<div class="stat-card category-feature_request">
<div class="stat-value">{{ category_counts.get('feature_request', 0) }}</div>
<div class="stat-label">Propozycji</div>
</div>
<div class="stat-card category-bug">
<div class="stat-value">{{ category_counts.get('bug', 0) }}</div>
<div class="stat-label">Bledow</div>
</div>
<div class="stat-card category-question">
<div class="stat-value">{{ category_counts.get('question', 0) }}</div>
<div class="stat-label">Pytan</div>
</div>
<div class="stat-card category-announcement">
<div class="stat-value">{{ category_counts.get('announcement', 0) }}</div>
<div class="stat-label">Ogloszen</div>
</div>
</div>
<!-- Topics Section -->
<div class="section">
<h2>Tematy</h2>
<!-- Bulk Actions Bar -->
<div class="bulk-actions-bar" id="bulkActionsBar">
<span class="selected-count"><span id="selectedCount">0</span> zaznaczonych</span>
<div class="bulk-actions">
<button class="bulk-btn" onclick="bulkAction('pin')" title="Przypnij zaznaczone">
📌 Przypnij
</button>
<button class="bulk-btn" onclick="bulkAction('unpin')" title="Odepnij zaznaczone">
📍 Odepnij
</button>
<button class="bulk-btn" onclick="bulkAction('lock')" title="Zablokuj zaznaczone">
🔒 Zablokuj
</button>
<button class="bulk-btn" onclick="bulkAction('unlock')" title="Odblokuj zaznaczone">
🔓 Odblokuj
</button>
<select id="bulkStatusSelect" class="bulk-btn" style="background:rgba(255,255,255,0.2)" onchange="bulkAction('status')">
<option value="">Zmień status...</option>
<option value="new">Nowy</option>
<option value="in_progress">W realizacji</option>
<option value="resolved">Rozwiązany</option>
<option value="rejected">Odrzucony</option>
</select>
<button class="bulk-btn danger" onclick="bulkAction('delete')" title="Usuń zaznaczone">
🗑️ Usuń
</button>
</div>
</div>
{% if topics %}
<table class="topics-table">
<thead>
<tr>
<th style="width:40px"><input type="checkbox" class="bulk-checkbox" id="selectAll" onchange="toggleSelectAll()"></th>
<th>Tytul</th>
<th>Kategoria</th>
<th>Autor</th>
<th>Status</th>
<th>Data</th>
<th>Akcje</th>
</tr>
</thead>
<tbody>
{% for topic in topics %}
<tr data-topic-id="{{ topic.id }}">
<td>
<input type="checkbox" class="bulk-checkbox topic-checkbox" value="{{ topic.id }}" onchange="updateBulkSelection()">
</td>
<td>
<div class="topic-title">
<a href="{{ url_for('forum_topic', topic_id=topic.id) }}">{{ topic.title }}</a>
</div>
{% if topic.is_pinned %}
<span class="badge badge-pinned">Przypiety</span>
{% endif %}
{% if topic.is_locked %}
<span class="badge badge-locked">Zamkniety</span>
{% endif %}
</td>
<td>
<span class="badge badge-category badge-{{ topic.category or 'question' }}">
{{ category_labels.get(topic.category, 'Pytanie') }}
</span>
</td>
<td class="topic-meta">{{ topic.author.name or topic.author.email.split('@')[0] }}</td>
<td>
<span class="badge badge-status badge-{{ topic.status or 'new' }}"
onclick="openStatusModal({{ topic.id }}, '{{ topic.title|e }}', '{{ topic.status or 'new' }}')"
title="Kliknij, aby zmienic status">
{{ status_labels.get(topic.status, 'Nowy') }}
</span>
</td>
<td class="topic-meta">{{ topic.created_at.strftime('%d.%m.%Y') }}</td>
<td>
<div class="action-buttons">
<button class="btn-icon {% if topic.is_pinned %}active{% endif %}"
onclick="togglePin({{ topic.id }})"
title="{% if topic.is_pinned %}Odepnij{% else %}Przypnij{% endif %}">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"></path>
</svg>
</button>
<button class="btn-icon {% if topic.is_locked %}active{% endif %}"
onclick="toggleLock({{ topic.id }})"
title="{% if topic.is_locked %}Odblokuj{% else %}Zablokuj{% endif %}">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0110 0v4"></path>
</svg>
</button>
<button class="btn-icon danger"
onclick="deleteTopic({{ topic.id }}, '{{ topic.title|e }}')"
title="Usun temat">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<p>Brak tematow na forum</p>
</div>
{% endif %}
</div>
<!-- Recent Replies Section -->
<div class="section">
<h2>Ostatnie odpowiedzi</h2>
{% if recent_replies %}
<div class="replies-list">
{% for reply in recent_replies %}
<div class="reply-item" data-reply-id="{{ reply.id }}">
<div>
<div class="reply-content">{{ reply.content[:200] }}{% if reply.content|length > 200 %}...{% endif %}</div>
<div class="reply-meta">
{{ reply.author.name or reply.author.email.split('@')[0] }}
w temacie <a href="{{ url_for('forum_topic', topic_id=reply.topic_id) }}">{{ reply.topic.title[:30] }}{% if reply.topic.title|length > 30 %}...{% endif %}</a>
&bull; {{ reply.created_at.strftime('%d.%m.%Y %H:%M') }}
</div>
</div>
<button class="btn-icon danger"
onclick="deleteReply({{ reply.id }})"
title="Usun odpowiedz">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<p>Brak odpowiedzi</p>
</div>
{% endif %}
</div>
<!-- Status Change Modal -->
<div class="modal-overlay" id="statusModal">
<div class="modal-content">
<div class="modal-header">Zmien status tematu</div>
<div class="modal-body">
<p id="modalTopicTitle" style="margin-bottom: var(--spacing-md); color: var(--text-secondary);"></p>
<div class="form-group">
<label class="form-label">Nowy status</label>
<select class="form-select" id="newStatus">
<option value="new">Nowy</option>
<option value="in_progress">W realizacji</option>
<option value="resolved">Rozwiazany</option>
<option value="rejected">Odrzucony</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Notatka (opcjonalnie)</label>
<input type="text" class="form-input" id="statusNote" placeholder="Krotki komentarz do zmiany statusu...">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" onclick="closeStatusModal()">Anuluj</button>
<button class="btn btn-primary" onclick="saveStatus()">Zapisz</button>
</div>
</div>
</div>
<!-- Universal Confirm Modal -->
<div class="modal-overlay" id="confirmModal">
<div class="modal-content" style="max-width: 420px;">
<div style="text-align: center; margin-bottom: var(--spacing-lg);">
<div class="modal-icon" id="confirmModalIcon" style="font-size: 3em; margin-bottom: var(--spacing-md);"></div>
<h3 id="confirmModalTitle" style="margin-bottom: var(--spacing-sm);">Potwierdzenie</h3>
<p class="modal-description" id="confirmModalMessage" style="color: var(--text-secondary);"></p>
</div>
<div class="modal-footer" style="justify-content: center;">
<button type="button" class="btn btn-outline" id="confirmModalCancel">Anuluj</button>
<button type="button" class="btn btn-primary" id="confirmModalOk">OK</button>
</div>
</div>
</div>
<div id="toastContainer" style="position: fixed; top: 80px; right: 20px; z-index: 1100; display: flex; flex-direction: column; gap: 10px;"></div>
<style>
.toast { padding: 12px 20px; border-radius: var(--radius); background: var(--surface); border-left: 4px solid var(--primary); box-shadow: 0 4px 12px rgba(0,0,0,0.15); display: flex; align-items: center; gap: 10px; animation: toastIn 0.3s ease; }
.toast.success { border-left-color: var(--success); }
.toast.error { border-left-color: var(--error); }
.toast.warning { border-left-color: var(--warning); }
@keyframes toastIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
@keyframes toastOut { from { opacity: 1; } to { opacity: 0; } }
</style>
{% endblock %}
{% block extra_js %}
const csrfToken = '{{ csrf_token() }}';
let currentTopicId = null;
let confirmResolve = null;
function showConfirm(message, options = {}) {
return new Promise(resolve => {
confirmResolve = resolve;
document.getElementById('confirmModalIcon').textContent = options.icon || '❓';
document.getElementById('confirmModalTitle').textContent = options.title || 'Potwierdzenie';
document.getElementById('confirmModalMessage').innerHTML = message;
document.getElementById('confirmModalOk').textContent = options.okText || 'OK';
document.getElementById('confirmModalOk').className = 'btn ' + (options.okClass || 'btn-primary');
document.getElementById('confirmModal').classList.add('active');
});
}
function closeConfirm(result) {
document.getElementById('confirmModal').classList.remove('active');
if (confirmResolve) { confirmResolve(result); confirmResolve = null; }
}
document.getElementById('confirmModalOk').addEventListener('click', () => closeConfirm(true));
document.getElementById('confirmModalCancel').addEventListener('click', () => closeConfirm(false));
document.getElementById('confirmModal').addEventListener('click', e => { if (e.target.id === 'confirmModal') closeConfirm(false); });
function showToast(message, type = 'info', duration = 4000) {
const container = document.getElementById('toastContainer');
const icons = { success: '✓', error: '✕', warning: '⚠', info: '' };
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.innerHTML = `<span style="font-size:1.2em">${icons[type]||''}</span><span>${message}</span>`;
container.appendChild(toast);
setTimeout(() => { toast.style.animation = 'toastOut 0.3s ease forwards'; setTimeout(() => toast.remove(), 300); }, duration);
}
function showMessage(message, type) {
showToast(message, type === 'error' ? 'error' : 'success');
}
// Status modal functions
function openStatusModal(topicId, topicTitle, currentStatus) {
currentTopicId = topicId;
document.getElementById('modalTopicTitle').textContent = topicTitle;
document.getElementById('newStatus').value = currentStatus;
document.getElementById('statusNote').value = '';
document.getElementById('statusModal').classList.add('active');
}
function closeStatusModal() {
document.getElementById('statusModal').classList.remove('active');
currentTopicId = null;
}
async function saveStatus() {
if (!currentTopicId) return;
const newStatus = document.getElementById('newStatus').value;
const statusNote = document.getElementById('statusNote').value;
try {
const response = await fetch(`/admin/forum/topic/${currentTopicId}/status`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
status: newStatus,
note: statusNote
})
});
const data = await response.json();
if (data.success) {
location.reload();
} else {
showMessage(data.error || 'Wystapil blad', 'error');
}
} catch (error) {
showMessage('Blad polaczenia', 'error');
}
closeStatusModal();
}
// Close modal on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeStatusModal();
}
});
// Close modal on overlay click
document.getElementById('statusModal').addEventListener('click', (e) => {
if (e.target.id === 'statusModal') {
closeStatusModal();
}
});
async function togglePin(topicId) {
try {
const response = await fetch(`/admin/forum/topic/${topicId}/pin`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
const data = await response.json();
if (data.success) {
location.reload();
} else {
showMessage(data.error || 'Wystapil blad', 'error');
}
} catch (error) {
showMessage('Blad polaczenia', 'error');
}
}
async function toggleLock(topicId) {
try {
const response = await fetch(`/admin/forum/topic/${topicId}/lock`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
const data = await response.json();
if (data.success) {
location.reload();
} else {
showMessage(data.error || 'Wystapil blad', 'error');
}
} catch (error) {
showMessage('Blad polaczenia', 'error');
}
}
async function deleteTopic(topicId, title) {
const confirmed = await showConfirm(`Czy na pewno chcesz usunąć temat "<strong>${title}</strong>"?<br><br><small>Ta operacja usunie również wszystkie odpowiedzi i jest nieodwracalna.</small>`, {
icon: '🗑️',
title: 'Usuwanie tematu',
okText: 'Usuń',
okClass: 'btn-danger'
});
if (!confirmed) return;
try {
const response = await fetch(`/admin/forum/topic/${topicId}/delete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
const data = await response.json();
if (data.success) {
document.querySelector(`tr[data-topic-id="${topicId}"]`).remove();
showToast('Temat został usunięty', 'success');
} else {
showMessage(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showMessage('Błąd połączenia', 'error');
}
}
async function deleteReply(replyId) {
const confirmed = await showConfirm('Czy na pewno chcesz usunąć tę odpowiedź?', {
icon: '🗑️',
title: 'Usuwanie odpowiedzi',
okText: 'Usuń',
okClass: 'btn-danger'
});
if (!confirmed) return;
try {
const response = await fetch(`/admin/forum/reply/${replyId}/delete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
const data = await response.json();
if (data.success) {
document.querySelector(`div[data-reply-id="${replyId}"]`).remove();
showToast('Odpowiedź została usunięta', 'success');
} else {
showMessage(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showMessage('Błąd połączenia', 'error');
}
}
// ============================================================
// BULK ACTIONS
// ============================================================
function getSelectedTopicIds() {
return Array.from(document.querySelectorAll('.topic-checkbox:checked')).map(cb => parseInt(cb.value));
}
function updateBulkSelection() {
const selected = getSelectedTopicIds();
const bar = document.getElementById('bulkActionsBar');
const count = document.getElementById('selectedCount');
const selectAll = document.getElementById('selectAll');
count.textContent = selected.length;
if (selected.length > 0) {
bar.classList.add('visible');
} else {
bar.classList.remove('visible');
}
// Update select all checkbox state
const allCheckboxes = document.querySelectorAll('.topic-checkbox');
selectAll.checked = allCheckboxes.length > 0 && selected.length === allCheckboxes.length;
selectAll.indeterminate = selected.length > 0 && selected.length < allCheckboxes.length;
// Highlight selected rows
document.querySelectorAll('.topics-table tbody tr').forEach(row => {
const checkbox = row.querySelector('.topic-checkbox');
if (checkbox && checkbox.checked) {
row.classList.add('selected');
} else {
row.classList.remove('selected');
}
});
}
function toggleSelectAll() {
const selectAll = document.getElementById('selectAll');
document.querySelectorAll('.topic-checkbox').forEach(cb => {
cb.checked = selectAll.checked;
});
updateBulkSelection();
}
async function bulkAction(action) {
const topicIds = getSelectedTopicIds();
if (topicIds.length === 0) {
showToast('Zaznacz tematy', 'warning');
return;
}
let confirmMessage = '';
let payload = { topic_ids: topicIds, action: action };
switch (action) {
case 'pin':
confirmMessage = `Przypnij ${topicIds.length} tematów?`;
break;
case 'unpin':
confirmMessage = `Odepnij ${topicIds.length} tematów?`;
break;
case 'lock':
confirmMessage = `Zablokuj ${topicIds.length} tematów?`;
break;
case 'unlock':
confirmMessage = `Odblokuj ${topicIds.length} tematów?`;
break;
case 'status':
const status = document.getElementById('bulkStatusSelect').value;
if (!status) return;
payload.status = status;
confirmMessage = `Zmień status ${topicIds.length} tematów na "${status}"?`;
document.getElementById('bulkStatusSelect').value = '';
break;
case 'delete':
confirmMessage = `<strong>Uwaga!</strong> Ta operacja jest nieodwracalna.<br><br>Usunąć ${topicIds.length} tematów wraz ze wszystkimi odpowiedziami?`;
break;
default:
return;
}
const confirmed = await showConfirm(confirmMessage, {
title: 'Akcja zbiorcza',
icon: action === 'delete' ? '⚠️' : '❓',
okText: action === 'delete' ? 'Usuń' : 'OK',
okClass: action === 'delete' ? 'btn-danger' : 'btn-primary'
});
if (!confirmed) return;
try {
const response = await fetch('/admin/forum/bulk-action', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify(payload)
});
const data = await response.json();
if (data.success) {
showToast(data.message || 'Operacja wykonana', 'success');
setTimeout(() => location.reload(), 1000);
} else {
showToast(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showToast('Błąd połączenia', 'error');
}
}
{% endblock %}