nordabiz/templates/admin/forum.html
Maciej Pienczyn 6e00291a88 feat: AI usage user details + styled modals across app
- Add /admin/ai-usage/user/<id> route for detailed AI usage per user
- Add ai_usage_user.html template with stats, usage breakdown, logs
- Make user names clickable in AI usage dashboard ranking
- Replace all native browser dialogs (alert, confirm) with styled modals/toasts:
  - admin/fees.html, forum.html, recommendations.html, announcements.html, debug.html
  - calendar/admin.html, event.html
  - company_detail.html, company/recommend.html
  - forum/new_topic.html, topic.html
  - classifieds/view.html
  - auth/reset_password.html

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 10:30:35 +01:00

783 lines
25 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 Hub{% 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;
}
}
</style>
{% endblock %}
{% block content %}
<div class="admin-header">
<h1>Moderacja Forum</h1>
<p class="text-muted">Zarzadzaj tematami i odpowiedziami na forum</p>
</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>
{% if topics %}
<table class="topics-table">
<thead>
<tr>
<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>
<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');
}
}
{% endblock %}