nordabiz/templates/admin/forum.html
Maciej Pienczyn 39da377065
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
fix: UTC timezone correction for all JS date parsing across portal
Added global parseUTC() helper in base.html that appends 'Z' to
naive ISO dates from server. Applied to:
- Notification bell (base.html) — formatTimeAgo
- NordaGPT conversation sort (chat.html)
- B2B interest dates (classifieds/view.html)
- Admin forum moderation dates (admin/forum.html)
- Admin AI insights dates (admin/insights.html)

Same fix as conversations.js parseUTC, now available globally.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 06:09:42 +02:00

1441 lines
52 KiB
HTML
Executable File
Raw Permalink 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); flex-wrap: wrap;">
<a href="{{ url_for('forum.admin_forum_analytics') }}" class="btn btn-primary" 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="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
Analityka
</a>
<a href="{{ url_for('forum.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('forum.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>
<!-- Admin Search Section -->
<div class="section" style="margin-bottom: var(--spacing-xl);">
<h2 style="margin-bottom: var(--spacing-md);">Wyszukiwarka</h2>
<div style="display: flex; gap: var(--spacing-md); align-items: center; flex-wrap: wrap;">
<input type="text" id="adminSearchQuery" class="form-input" placeholder="Szukaj we wszystkich postach..." style="flex: 1; min-width: 200px;">
<label style="display: flex; align-items: center; gap: var(--spacing-xs); font-size: var(--font-size-sm);">
<input type="checkbox" id="includeDeleted"> Uwzględnij usunięte
</label>
<button class="btn btn-primary" onclick="adminSearch()">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
Szukaj
</button>
</div>
<div id="searchResults" style="display: none; margin-top: var(--spacing-lg);">
<h3 style="font-size: var(--font-size-base); margin-bottom: var(--spacing-md);">Wyniki wyszukiwania (<span id="resultCount">0</span>)</h3>
<div id="searchResultsList" style="max-height: 400px; overflow-y: auto;"></div>
</div>
</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" onclick="openMergeModal()" title="Połącz zaznaczone tematy">
🔗 Połącz
</button>
<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|local_time('%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"
onclick="openMoveModal({{ topic.id }}, '{{ topic.title|e }}', '{{ topic.category or 'question' }}')"
title="Przenies do innej kategorii">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
</svg>
</button>
<button class="btn-icon"
onclick="showUserActivity({{ topic.author_id }})"
title="Aktywnosc autora">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</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|local_time('%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>
<!-- Move Topic Modal -->
<div class="modal-overlay" id="moveTopicModal">
<div class="modal-content">
<div class="modal-header">Przenies temat</div>
<div class="modal-body">
<p id="moveModalTopicTitle" style="margin-bottom: var(--spacing-md); color: var(--text-secondary);"></p>
<div class="form-group">
<label class="form-label">Nowa kategoria</label>
<select class="form-select" id="newCategorySelect">
<option value="feature_request">Propozycja funkcji</option>
<option value="bug">Blad</option>
<option value="question">Pytanie</option>
<option value="announcement">Ogloszenie</option>
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" onclick="closeMoveModal()">Anuluj</button>
<button class="btn btn-primary" onclick="saveMoveCategory()">Przenies</button>
</div>
</div>
</div>
<!-- Merge Topics Modal -->
<div class="modal-overlay" id="mergeTopicsModal">
<div class="modal-content">
<div class="modal-header">Polacz tematy</div>
<div class="modal-body">
<p style="margin-bottom: var(--spacing-md); color: var(--text-secondary);">
Wybierz temat docelowy. Pozostale tematy zostana do niego przeniesione.
</p>
<div class="form-group">
<label class="form-label">Temat docelowy</label>
<select class="form-select" id="targetTopicSelect"></select>
</div>
<div id="mergeInfo" style="font-size: var(--font-size-sm); color: var(--text-muted); margin-top: var(--spacing-md);"></div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" onclick="closeMergeModal()">Anuluj</button>
<button class="btn btn-primary" onclick="executeMerge()">Polacz</button>
</div>
</div>
</div>
<!-- User Activity Modal -->
<div class="modal-overlay" id="userActivityModal">
<div class="modal-content" style="max-width: 600px;">
<div class="modal-header">
Aktywnosc uzytkownika: <span id="activityUserName"></span>
</div>
<div class="modal-body">
<div id="activityStats" style="display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--spacing-md); margin-bottom: var(--spacing-lg);">
<div style="text-align: center;">
<div style="font-size: var(--font-size-2xl); font-weight: bold; color: var(--primary);" id="statTopics">0</div>
<div style="font-size: var(--font-size-xs); color: var(--text-secondary);">Tematy</div>
</div>
<div style="text-align: center;">
<div style="font-size: var(--font-size-2xl); font-weight: bold; color: var(--success);" id="statReplies">0</div>
<div style="font-size: var(--font-size-xs); color: var(--text-secondary);">Odpowiedzi</div>
</div>
<div style="text-align: center;">
<div style="font-size: var(--font-size-2xl); font-weight: bold; color: var(--warning);" id="statSolutions">0</div>
<div style="font-size: var(--font-size-xs); color: var(--text-secondary);">Rozwiazania</div>
</div>
<div style="text-align: center;">
<div style="font-size: var(--font-size-2xl); font-weight: bold; color: var(--info);" id="statTotal">0</div>
<div style="font-size: var(--font-size-xs); color: var(--text-secondary);">Lacznie</div>
</div>
</div>
<h4 style="margin-bottom: var(--spacing-sm);">Historia aktywnosci</h4>
<div id="activityLog" style="max-height: 300px; overflow-y: auto;">
<p class="text-muted">Ladowanie...</p>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" onclick="closeActivityModal()">Zamknij</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; } }
/* Search results */
.search-result-item {
padding: var(--spacing-md);
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--spacing-md);
}
.search-result-item:hover { background: var(--background); }
.search-result-item.deleted { opacity: 0.6; }
.search-result-item .result-type {
font-size: var(--font-size-xs);
padding: 2px 6px;
border-radius: var(--radius-sm);
background: var(--background);
}
.search-result-item .result-type.topic { background: #dbeafe; color: #1e40af; }
.search-result-item .result-type.reply { background: #dcfce7; color: #166534; }
.search-result-item .result-content { flex: 1; }
.search-result-item .result-title { font-weight: 500; color: var(--text-primary); text-decoration: none; }
.search-result-item .result-title:hover { color: var(--primary); }
.search-result-item .result-preview { font-size: var(--font-size-sm); color: var(--text-secondary); margin-top: var(--spacing-xs); }
.search-result-item .result-meta { font-size: var(--font-size-xs); color: var(--text-muted); margin-top: var(--spacing-xs); }
/* Activity log */
.activity-item {
padding: var(--spacing-sm) 0;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.activity-item:last-child { border-bottom: none; }
.activity-item .activity-icon {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
}
.activity-item .activity-icon.topic { background: #dbeafe; }
.activity-item .activity-icon.reply { background: #dcfce7; }
.activity-item .activity-icon.solution { background: #fef3c7; }
.activity-item a { color: var(--text-primary); text-decoration: none; }
.activity-item a:hover { color: var(--primary); }
.activity-item .activity-date { font-size: var(--font-size-xs); color: var(--text-muted); margin-left: auto; }
</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');
}
}
// ============================================================
// ADMIN SEARCH
// ============================================================
async function adminSearch() {
const query = document.getElementById('adminSearchQuery').value.trim();
const includeDeleted = document.getElementById('includeDeleted').checked;
if (query.length < 2) {
showToast('Wpisz co najmniej 2 znaki', 'warning');
return;
}
try {
const url = `/admin/forum/search?q=${encodeURIComponent(query)}${includeDeleted ? '&deleted=1' : ''}`;
const response = await fetch(url, {
headers: { 'X-CSRFToken': csrfToken }
});
const data = await response.json();
if (data.success) {
displaySearchResults(data.results, data.count);
} else {
showToast(data.error || 'Błąd wyszukiwania', 'error');
}
} catch (error) {
showToast('Błąd połączenia', 'error');
}
}
function displaySearchResults(results, count) {
const container = document.getElementById('searchResults');
const list = document.getElementById('searchResultsList');
const countSpan = document.getElementById('resultCount');
countSpan.textContent = count;
if (results.length === 0) {
list.innerHTML = '<p class="text-muted" style="padding: var(--spacing-lg);">Brak wyników</p>';
} else {
list.innerHTML = results.map(r => `
<div class="search-result-item ${r.is_deleted ? 'deleted' : ''}">
<span class="result-type ${r.type}">${r.type === 'topic' ? 'Temat' : 'Odpowiedź'}</span>
<div class="result-content">
<a href="${r.url}" class="result-title">${r.title}</a>
${r.is_deleted ? '<span style="color: var(--error); font-size: var(--font-size-xs);"> (usunięty)</span>' : ''}
<div class="result-preview">${r.content_preview}</div>
<div class="result-meta">
${r.author_name} &bull; ${parseUTC(r.created_at).toLocaleString('pl-PL')}
${r.category_label ? ` &bull; ${r.category_label}` : ''}
</div>
</div>
</div>
`).join('');
}
container.style.display = 'block';
}
// Search on Enter key
document.getElementById('adminSearchQuery').addEventListener('keypress', (e) => {
if (e.key === 'Enter') adminSearch();
});
// ============================================================
// MOVE TOPIC
// ============================================================
let currentMoveTopicId = null;
function openMoveModal(topicId, title, currentCategory) {
currentMoveTopicId = topicId;
document.getElementById('moveModalTopicTitle').textContent = title;
document.getElementById('newCategorySelect').value = currentCategory;
document.getElementById('moveTopicModal').classList.add('active');
}
function closeMoveModal() {
document.getElementById('moveTopicModal').classList.remove('active');
currentMoveTopicId = null;
}
async function saveMoveCategory() {
if (!currentMoveTopicId) return;
const newCategory = document.getElementById('newCategorySelect').value;
try {
const response = await fetch(`/admin/forum/topic/${currentMoveTopicId}/move`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ category: newCategory })
});
const data = await response.json();
if (data.success) {
showToast(data.message, 'success');
setTimeout(() => location.reload(), 1000);
} else {
showToast(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showToast('Błąd połączenia', 'error');
}
closeMoveModal();
}
document.getElementById('moveTopicModal').addEventListener('click', (e) => {
if (e.target.id === 'moveTopicModal') closeMoveModal();
});
// ============================================================
// MERGE TOPICS
// ============================================================
function openMergeModal() {
const topicIds = getSelectedTopicIds();
if (topicIds.length < 2) {
showToast('Zaznacz co najmniej 2 tematy do połączenia', 'warning');
return;
}
const select = document.getElementById('targetTopicSelect');
select.innerHTML = '';
// Populate select with selected topics
topicIds.forEach(id => {
const row = document.querySelector(`tr[data-topic-id="${id}"]`);
const title = row ? row.querySelector('.topic-title a').textContent : `Temat #${id}`;
const option = document.createElement('option');
option.value = id;
option.textContent = title;
select.appendChild(option);
});
document.getElementById('mergeInfo').textContent = `${topicIds.length} tematów zaznaczonych. Wybierz temat docelowy - pozostałe zostaną do niego przeniesione.`;
document.getElementById('mergeTopicsModal').classList.add('active');
}
function closeMergeModal() {
document.getElementById('mergeTopicsModal').classList.remove('active');
}
async function executeMerge() {
const topicIds = getSelectedTopicIds();
const targetId = parseInt(document.getElementById('targetTopicSelect').value);
const sourceIds = topicIds.filter(id => id !== targetId);
if (sourceIds.length === 0) {
showToast('Nie można połączyć - brak tematów źródłowych', 'warning');
return;
}
const confirmed = await showConfirm(`Połączyć ${sourceIds.length} tematów z wybranym tematem docelowym?<br><br><small>Ta operacja przeniesie wszystkie odpowiedzi i usunie tematy źródłowe.</small>`, {
icon: '🔗',
title: 'Łączenie tematów',
okText: 'Połącz'
});
if (!confirmed) return;
try {
const response = await fetch('/admin/forum/merge-topics', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
target_id: targetId,
source_ids: sourceIds
})
});
const data = await response.json();
if (data.success) {
showToast(data.message, 'success');
setTimeout(() => location.reload(), 1000);
} else {
showToast(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showToast('Błąd połączenia', 'error');
}
closeMergeModal();
}
document.getElementById('mergeTopicsModal').addEventListener('click', (e) => {
if (e.target.id === 'mergeTopicsModal') closeMergeModal();
});
// ============================================================
// USER ACTIVITY
// ============================================================
async function showUserActivity(userId) {
document.getElementById('userActivityModal').classList.add('active');
document.getElementById('activityLog').innerHTML = '<p class="text-muted">Ładowanie...</p>';
try {
const response = await fetch(`/admin/forum/user/${userId}/activity`, {
headers: { 'X-CSRFToken': csrfToken }
});
const data = await response.json();
if (data.success) {
displayUserActivity(data);
} else {
showToast(data.error || 'Błąd ładowania aktywności', 'error');
closeActivityModal();
}
} catch (error) {
showToast('Błąd połączenia', 'error');
closeActivityModal();
}
}
function displayUserActivity(data) {
document.getElementById('activityUserName').textContent = data.user.name;
document.getElementById('statTopics').textContent = data.stats.topics;
document.getElementById('statReplies').textContent = data.stats.replies;
document.getElementById('statSolutions').textContent = data.stats.solutions;
document.getElementById('statTotal').textContent = data.stats.total_posts;
const log = document.getElementById('activityLog');
if (data.activity.length === 0) {
log.innerHTML = '<p class="text-muted">Brak aktywności</p>';
} else {
log.innerHTML = data.activity.map(a => `
<div class="activity-item">
<span class="activity-icon ${a.type} ${a.is_solution ? 'solution' : ''}">
${a.type === 'topic' ? '📝' : (a.is_solution ? '✓' : '💬')}
</span>
<div>
<span>${a.action}</span>
<a href="${a.url}">${a.title}</a>
${a.is_deleted ? '<span style="color: var(--error);"> (usunięty)</span>' : ''}
</div>
<span class="activity-date">${parseUTC(a.created_at).toLocaleString('pl-PL')}</span>
</div>
`).join('');
}
}
function closeActivityModal() {
document.getElementById('userActivityModal').classList.remove('active');
}
document.getElementById('userActivityModal').addEventListener('click', (e) => {
if (e.target.id === 'userActivityModal') closeActivityModal();
});
// Close all modals on Escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeMoveModal();
closeMergeModal();
closeActivityModal();
}
});
{% endblock %}