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>
This commit is contained in:
Maciej Pienczyn 2026-01-31 19:11:29 +01:00
parent f22342ea37
commit c5f724f954
8 changed files with 900 additions and 15 deletions

4
app.py
View File

@ -244,6 +244,10 @@ def ensure_url_filter(url):
return f'https://{url}' return f'https://{url}'
return url return url
# Register forum markdown filter
from utils.markdown import register_markdown_filter
register_markdown_filter(app)
# Initialize extensions from centralized extensions.py # Initialize extensions from centralized extensions.py
from extensions import csrf, limiter, login_manager from extensions import csrf, limiter, login_manager

View File

@ -215,6 +215,8 @@ def register_blueprints(app):
'topic_edit_history': 'forum.topic_edit_history', 'topic_edit_history': 'forum.topic_edit_history',
'reply_edit_history': 'forum.reply_edit_history', 'reply_edit_history': 'forum.reply_edit_history',
'admin_deleted_content': 'forum.admin_deleted_content', 'admin_deleted_content': 'forum.admin_deleted_content',
'user_forum_stats': 'forum.user_forum_stats',
'admin_forum_bulk_action': 'forum.admin_forum_bulk_action',
}) })
logger.info("Created forum endpoint aliases") logger.info("Created forum endpoint aliases")
except ImportError as e: except ImportError as e:

View File

@ -21,7 +21,8 @@ from utils.notifications import (
create_forum_reply_notification, create_forum_reply_notification,
create_forum_reaction_notification, create_forum_reaction_notification,
create_forum_solution_notification, create_forum_solution_notification,
create_forum_report_notification create_forum_report_notification,
parse_mentions_and_notify
) )
# Constants # Constants
@ -47,11 +48,13 @@ except ImportError:
@bp.route('/forum') @bp.route('/forum')
@login_required @login_required
def forum_index(): def forum_index():
"""Forum - list of topics with category/status filters""" """Forum - list of topics with category/status/solution filters and search"""
page = request.args.get('page', 1, type=int) page = request.args.get('page', 1, type=int)
per_page = 20 per_page = 20
category_filter = request.args.get('category', '') category_filter = request.args.get('category', '')
status_filter = request.args.get('status', '') status_filter = request.args.get('status', '')
has_solution = request.args.get('has_solution', '')
search_query = request.args.get('q', '').strip()
db = SessionLocal() db = SessionLocal()
try: try:
@ -66,6 +69,25 @@ def forum_index():
if status_filter and status_filter in ForumTopic.STATUSES: if status_filter and status_filter in ForumTopic.STATUSES:
query = query.filter(ForumTopic.status == status_filter) query = query.filter(ForumTopic.status == status_filter)
# Filter by has solution
if has_solution == '1':
# Topics that have at least one reply marked as solution
from sqlalchemy import exists
query = query.filter(
exists().where(
(ForumReply.topic_id == ForumTopic.id) &
(ForumReply.is_solution == True)
)
)
# Search in title and content
if search_query:
search_term = f'%{search_query}%'
query = query.filter(
(ForumTopic.title.ilike(search_term)) |
(ForumTopic.content.ilike(search_term))
)
# Order by pinned first, then by last activity # Order by pinned first, then by last activity
query = query.order_by( query = query.order_by(
ForumTopic.is_pinned.desc(), ForumTopic.is_pinned.desc(),
@ -84,6 +106,8 @@ def forum_index():
total_pages=(total_topics + per_page - 1) // per_page, total_pages=(total_topics + per_page - 1) // per_page,
category_filter=category_filter, category_filter=category_filter,
status_filter=status_filter, status_filter=status_filter,
has_solution=has_solution,
search_query=search_query,
categories=ForumTopic.CATEGORIES, categories=ForumTopic.CATEGORIES,
statuses=ForumTopic.STATUSES, statuses=ForumTopic.STATUSES,
category_labels=ForumTopic.CATEGORY_LABELS, category_labels=ForumTopic.CATEGORY_LABELS,
@ -160,6 +184,20 @@ def forum_new_topic():
else: else:
flash(f'Załącznik: {error_msg}', 'warning') flash(f'Załącznik: {error_msg}', 'warning')
# Parse @mentions and send notifications
try:
author_name = current_user.name or current_user.email.split('@')[0]
parse_mentions_and_notify(
content=content,
author_id=current_user.id,
author_name=author_name,
topic_id=topic.id,
content_type='topic',
content_id=topic.id
)
except Exception as e:
logger.warning(f"Failed to parse mentions in new topic: {e}")
flash('Temat został utworzony.', 'success') flash('Temat został utworzony.', 'success')
return redirect(url_for('.forum_topic', topic_id=topic.id)) return redirect(url_for('.forum_topic', topic_id=topic.id))
finally: finally:
@ -305,6 +343,20 @@ def forum_reply(topic_id):
except Exception as e: except Exception as e:
logger.warning(f"Failed to send reply notifications: {e}") logger.warning(f"Failed to send reply notifications: {e}")
# Parse @mentions and send notifications
try:
author_name = current_user.name or current_user.email.split('@')[0]
parse_mentions_and_notify(
content=content,
author_id=current_user.id,
author_name=author_name,
topic_id=topic_id,
content_type='reply',
content_id=reply.id
)
except Exception as e:
logger.warning(f"Failed to parse mentions: {e}")
flash('Odpowiedź dodana.', 'success') flash('Odpowiedź dodana.', 'success')
return redirect(url_for('.forum_topic', topic_id=topic_id)) return redirect(url_for('.forum_topic', topic_id=topic_id))
finally: finally:
@ -512,6 +564,88 @@ def admin_forum_change_status(topic_id):
db.close() db.close()
@bp.route('/admin/forum/bulk-action', methods=['POST'])
@login_required
def admin_forum_bulk_action():
"""Perform bulk action on multiple topics (admin only)"""
if not current_user.is_admin:
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
data = request.get_json() or {}
topic_ids = data.get('topic_ids', [])
action = data.get('action')
if not topic_ids or not isinstance(topic_ids, list):
return jsonify({'success': False, 'error': 'Nie wybrano tematów'}), 400
if action not in ['pin', 'unpin', 'lock', 'unlock', 'status', 'delete']:
return jsonify({'success': False, 'error': 'Nieprawidłowa akcja'}), 400
db = SessionLocal()
try:
topics = db.query(ForumTopic).filter(ForumTopic.id.in_(topic_ids)).all()
if not topics:
return jsonify({'success': False, 'error': 'Nie znaleziono tematów'}), 404
count = len(topics)
if action == 'pin':
for topic in topics:
topic.is_pinned = True
message = f'Przypięto {count} tematów'
elif action == 'unpin':
for topic in topics:
topic.is_pinned = False
message = f'Odpięto {count} tematów'
elif action == 'lock':
for topic in topics:
topic.is_locked = True
message = f'Zablokowano {count} tematów'
elif action == 'unlock':
for topic in topics:
topic.is_locked = False
message = f'Odblokowano {count} tematów'
elif action == 'status':
new_status = data.get('status')
if not new_status or new_status not in ForumTopic.STATUSES:
return jsonify({'success': False, 'error': 'Nieprawidłowy status'}), 400
for topic in topics:
topic.status = new_status
topic.status_changed_by = current_user.id
topic.status_changed_at = datetime.now()
status_label = ForumTopic.STATUS_LABELS.get(new_status, new_status)
message = f'Zmieniono status {count} tematów na: {status_label}'
elif action == 'delete':
# Soft delete topics
for topic in topics:
topic.is_deleted = True
topic.deleted_at = datetime.now()
topic.deleted_by = current_user.id
message = f'Usunięto {count} tematów'
db.commit()
logger.info(f"Admin {current_user.email} performed bulk action '{action}' on {count} topics: {topic_ids}")
return jsonify({
'success': True,
'message': message,
'affected_count': count
})
except Exception as e:
db.rollback()
logger.error(f"Error in bulk action: {e}")
return jsonify({'success': False, 'error': 'Wystąpił błąd'}), 500
finally:
db.close()
# ============================================================ # ============================================================
# USER FORUM ACTIONS (edit, delete, reactions, subscriptions) # USER FORUM ACTIONS (edit, delete, reactions, subscriptions)
# ============================================================ # ============================================================
@ -1303,3 +1437,83 @@ def admin_deleted_content():
) )
finally: finally:
db.close() db.close()
# ============================================================
# USER STATISTICS
# ============================================================
@bp.route('/forum/user/<int:user_id>/stats')
@login_required
def user_forum_stats(user_id):
"""Get forum statistics for a user (for tooltip display)"""
from sqlalchemy import func
db = SessionLocal()
try:
# Count topics created
topic_count = db.query(func.count(ForumTopic.id)).filter(
ForumTopic.author_id == user_id,
(ForumTopic.is_deleted == False) | (ForumTopic.is_deleted.is_(None))
).scalar() or 0
# Count replies created
reply_count = db.query(func.count(ForumReply.id)).filter(
ForumReply.author_id == user_id,
(ForumReply.is_deleted == False) | (ForumReply.is_deleted.is_(None))
).scalar() or 0
# Count solutions marked
solution_count = db.query(func.count(ForumReply.id)).filter(
ForumReply.author_id == user_id,
ForumReply.is_solution == True,
(ForumReply.is_deleted == False) | (ForumReply.is_deleted.is_(None))
).scalar() or 0
# Count reactions received on user's topics and replies
# Using JSONB - count non-empty reaction arrays
reactions_received = 0
# Get user's topics with reactions
user_topics = db.query(ForumTopic).filter(
ForumTopic.author_id == user_id,
ForumTopic.reactions.isnot(None)
).all()
for topic in user_topics:
if topic.reactions:
for emoji, user_ids in topic.reactions.items():
if isinstance(user_ids, list):
reactions_received += len(user_ids)
# Get user's replies with reactions
user_replies = db.query(ForumReply).filter(
ForumReply.author_id == user_id,
ForumReply.reactions.isnot(None)
).all()
for reply in user_replies:
if reply.reactions:
for emoji, user_ids in reply.reactions.items():
if isinstance(user_ids, list):
reactions_received += len(user_ids)
# Get user info
user = db.query(User).filter(User.id == user_id).first()
user_name = user.full_name if user else 'Nieznany'
return jsonify({
'success': True,
'user_id': user_id,
'user_name': user_name,
'stats': {
'topics': topic_count,
'replies': reply_count,
'solutions': solution_count,
'reactions_received': reactions_received,
'total_posts': topic_count + reply_count
}
})
except Exception as e:
logger.error(f"Error getting user stats: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()

View File

@ -342,6 +342,68 @@
display: none; 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> </style>
{% endblock %} {% endblock %}
@ -426,10 +488,41 @@
<!-- Topics Section --> <!-- Topics Section -->
<div class="section"> <div class="section">
<h2>Tematy</h2> <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 %} {% if topics %}
<table class="topics-table"> <table class="topics-table">
<thead> <thead>
<tr> <tr>
<th style="width:40px"><input type="checkbox" class="bulk-checkbox" id="selectAll" onchange="toggleSelectAll()"></th>
<th>Tytul</th> <th>Tytul</th>
<th>Kategoria</th> <th>Kategoria</th>
<th>Autor</th> <th>Autor</th>
@ -441,6 +534,9 @@
<tbody> <tbody>
{% for topic in topics %} {% for topic in topics %}
<tr data-topic-id="{{ topic.id }}"> <tr data-topic-id="{{ topic.id }}">
<td>
<input type="checkbox" class="bulk-checkbox topic-checkbox" value="{{ topic.id }}" onchange="updateBulkSelection()">
</td>
<td> <td>
<div class="topic-title"> <div class="topic-title">
<a href="{{ url_for('forum_topic', topic_id=topic.id) }}">{{ topic.title }}</a> <a href="{{ url_for('forum_topic', topic_id=topic.id) }}">{{ topic.title }}</a>
@ -791,4 +887,118 @@
showMessage('Błąd połączenia', '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 %} {% endblock %}

View File

@ -355,19 +355,29 @@
</a> </a>
</div> </div>
<!-- Filters - Uproszczone (ukryte kategorie i statusy) --> <!-- Search and Filters -->
<div class="filters-bar"> <div class="filters-bar">
<!-- Filtry kategorii i statusów ukryte - forum w fazie upraszczania --> <!-- Search box -->
<!-- Oryginalny kod zachowany w komentarzu na wypadek przywrócenia:
<div class="filter-group"> <div class="filter-group">
<span class="filter-label">Kategoria:</span> <form action="{{ url_for('forum_index') }}" method="GET" style="display: flex; gap: var(--spacing-sm);">
<a href="{{ url_for('forum_index', status=status_filter) }}" class="filter-btn active">Wszystkie</a> <input type="text" name="q" value="{{ search_query or '' }}" placeholder="Szukaj w forum..."
style="padding: var(--spacing-sm) var(--spacing-md); border: 1px solid var(--border); border-radius: var(--radius); font-size: var(--font-size-sm); min-width: 200px;">
<button type="submit" class="filter-btn" style="background: var(--primary); color: white; border-color: var(--primary);">
<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 21-4.35-4.35"/></svg>
</button>
{% if search_query %}
<a href="{{ url_for('forum_index') }}" class="filter-btn" title="Wyczyść wyszukiwanie"></a>
{% endif %}
</form>
</div> </div>
-->
<!-- Solution filter -->
<div class="filter-group"> <div class="filter-group">
<span class="filter-label" style="color: var(--text-secondary);">💬 Uwagi rozwojowe</span> <a href="{{ url_for('forum_index', q=search_query) }}" class="filter-btn {% if not has_solution %}active{% endif %}">Wszystkie</a>
<span style="color: var(--text-muted); font-size: var(--font-size-sm); margin-left: var(--spacing-sm);">Zapraszamy do podzielenia się swoimi opiniami</span> <a href="{{ url_for('forum_index', has_solution='1', q=search_query) }}" class="filter-btn {% if has_solution == '1' %}active{% endif %}">✓ Z rozwiązaniem</a>
</div> </div>
<!-- Toggle test topics -->
<div class="filter-group" style="margin-left: auto;"> <div class="filter-group" style="margin-left: auto;">
<button type="button" id="toggleTestBtn" class="filter-btn toggle-test-btn" onclick="toggleTestTopics()"> <button type="button" id="toggleTestBtn" class="filter-btn toggle-test-btn" onclick="toggleTestTopics()">
<span class="hide-label">🙈 Ukryj testowe</span> <span class="hide-label">🙈 Ukryj testowe</span>
@ -376,6 +386,12 @@
</div> </div>
</div> </div>
{% if search_query %}
<div style="margin-bottom: var(--spacing-lg); color: var(--text-secondary);">
Wyniki wyszukiwania dla: <strong>{{ search_query }}</strong> ({{ total_topics }} wyników)
</div>
{% endif %}
{% if topics %} {% if topics %}
<div class="topics-list"> <div class="topics-list">
{% for topic in topics %} {% for topic in topics %}
@ -394,6 +410,9 @@
<span class="topic-badge badge-status badge-{{ topic.status or 'new' }}"> <span class="topic-badge badge-status badge-{{ topic.status or 'new' }}">
{{ status_labels.get(topic.status, 'Nowy') }} {{ status_labels.get(topic.status, 'Nowy') }}
</span> </span>
{% if topic.replies and topic.replies|selectattr('is_solution')|list %}
<span class="topic-badge" style="background: #dcfce7; color: #166534; border: 1px solid #86efac;">✓ Rozwiązanie</span>
{% endif %}
{{ topic.title }} {{ topic.title }}
</a> </a>
<div class="topic-meta"> <div class="topic-meta">

View File

@ -144,7 +144,126 @@
.topic-content { .topic-content {
line-height: 1.8; line-height: 1.8;
color: var(--text-primary); color: var(--text-primary);
white-space: pre-wrap; }
/* Markdown styles */
.forum-quote {
border-left: 3px solid var(--primary);
background: var(--background);
padding: var(--spacing-sm) var(--spacing-md);
margin: var(--spacing-sm) 0;
font-style: italic;
color: var(--text-secondary);
}
.forum-list {
margin: var(--spacing-sm) 0;
padding-left: var(--spacing-xl);
}
.forum-list li {
margin: var(--spacing-xs) 0;
}
.forum-code {
background: var(--background);
padding: 2px 6px;
border-radius: 3px;
font-family: monospace;
font-size: 0.9em;
}
.forum-code-block {
background: #1e293b;
color: #e2e8f0;
padding: var(--spacing-md);
border-radius: var(--radius);
overflow-x: auto;
margin: var(--spacing-sm) 0;
}
.forum-code-block code {
font-family: monospace;
font-size: var(--font-size-sm);
}
.forum-link {
color: var(--primary);
text-decoration: underline;
}
.forum-mention {
background: #dbeafe;
color: #1e40af;
padding: 1px 4px;
border-radius: 3px;
font-weight: 500;
}
/* User stats tooltip */
.user-stats-trigger {
cursor: pointer;
position: relative;
font-weight: 500;
}
.user-stats-trigger:hover {
color: var(--primary);
}
.user-stats-tooltip {
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background: var(--card-bg, #fff);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 16px;
min-width: 200px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
z-index: 1000;
font-size: 13px;
margin-bottom: 8px;
}
.user-stats-tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: var(--border);
}
.user-stats-tooltip .stats-header {
font-weight: 600;
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.user-stats-tooltip .stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.user-stats-tooltip .stat-item {
display: flex;
justify-content: space-between;
color: var(--text-secondary);
}
.user-stats-tooltip .stat-value {
font-weight: 600;
color: var(--text-primary);
}
.user-stats-tooltip .loading {
color: var(--text-secondary);
text-align: center;
} }
/* Attachments */ /* Attachments */
@ -865,7 +984,9 @@
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path> <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle> <circle cx="12" cy="7" r="4"></circle>
</svg> </svg>
{{ topic.author.name or topic.author.email.split('@')[0] }} <span class="user-stats-trigger" data-user-id="{{ topic.author_id }}">
{{ topic.author.name or topic.author.email.split('@')[0] }}
</span>
{% if topic.is_ai_generated %} {% if topic.is_ai_generated %}
<span class="ai-indicator" title="Wygenerowano przez AI"> <span class="ai-indicator" title="Wygenerowano przez AI">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/></svg>
@ -895,7 +1016,7 @@
</button> </button>
</div> </div>
<div class="topic-content" id="topicContent">{{ topic.content }}</div> <div class="topic-content" id="topicContent">{{ topic.content|forum_markdown }}</div>
<!-- Reactions bar for topic --> <!-- Reactions bar for topic -->
<div class="reactions-bar" id="topicReactions" data-content-type="topic" data-content-id="{{ topic.id }}"> <div class="reactions-bar" id="topicReactions" data-content-type="topic" data-content-id="{{ topic.id }}">
@ -954,7 +1075,9 @@
<div class="reply-avatar"> <div class="reply-avatar">
{{ (reply.author.name or reply.author.email)[0].upper() }} {{ (reply.author.name or reply.author.email)[0].upper() }}
</div> </div>
{{ reply.author.name or reply.author.email.split('@')[0] }} <span class="user-stats-trigger" data-user-id="{{ reply.author_id }}">
{{ reply.author.name or reply.author.email.split('@')[0] }}
</span>
{% if reply.is_ai_generated %} {% if reply.is_ai_generated %}
<span class="ai-indicator" title="Wygenerowano przez AI"> <span class="ai-indicator" title="Wygenerowano przez AI">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/></svg>
@ -995,7 +1118,7 @@
{% if reply.is_deleted %} {% if reply.is_deleted %}
<div class="reply-content deleted-notice">[Ta odpowiedź została usunięta]</div> <div class="reply-content deleted-notice">[Ta odpowiedź została usunięta]</div>
{% else %} {% else %}
<div class="reply-content">{{ reply.content }}</div> <div class="reply-content">{{ reply.content|forum_markdown }}</div>
{% if reply.attachments %} {% if reply.attachments %}
<div class="reply-attachments-container"> <div class="reply-attachments-container">
@ -1039,6 +1162,10 @@
Usuń Usuń
</button> </button>
{% endif %} {% endif %}
<button type="button" class="action-btn" onclick="quoteReply('{{ reply.author.name or reply.author.email.split('@')[0] }}', document.querySelector('#reply-{{ reply.id }} .reply-content').innerText)">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="14" height="14"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>
Cytuj
</button>
<button type="button" class="action-btn" onclick="openReportModal('reply', {{ reply.id }})"> <button type="button" class="action-btn" onclick="openReportModal('reply', {{ reply.id }})">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="14" height="14"><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> <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="14" height="14"><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łoś Zgłoś
@ -1065,6 +1192,9 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<h3>Dodaj odpowiedź</h3> <h3>Dodaj odpowiedź</h3>
<textarea name="content" id="replyContent" placeholder="Twoja odpowiedź..." required></textarea> <textarea name="content" id="replyContent" placeholder="Twoja odpowiedź..." required></textarea>
<div style="font-size: var(--font-size-xs); color: var(--text-muted); margin-top: var(--spacing-xs);">
Formatowanie: **pogrubienie**, *kursywa*, `kod`, [link](url), @wzmianka, > cytat
</div>
<div class="upload-counter" id="uploadCounter"></div> <div class="upload-counter" id="uploadCounter"></div>
<div class="upload-previews-container" id="previewsContainer"></div> <div class="upload-previews-container" id="previewsContainer"></div>
@ -1237,6 +1367,95 @@
container.appendChild(toast); container.appendChild(toast);
setTimeout(() => { toast.style.animation = 'toastOut 0.3s ease forwards'; setTimeout(() => toast.remove(), 300); }, duration); setTimeout(() => { toast.style.animation = 'toastOut 0.3s ease forwards'; setTimeout(() => toast.remove(), 300); }, duration);
} }
// User stats tooltip
const userStatsCache = {};
let activeTooltip = null;
document.querySelectorAll('.user-stats-trigger').forEach(trigger => {
let hoverTimeout = null;
trigger.addEventListener('mouseenter', function() {
const userId = this.dataset.userId;
hoverTimeout = setTimeout(() => showUserStats(this, userId), 500);
});
trigger.addEventListener('mouseleave', function() {
clearTimeout(hoverTimeout);
setTimeout(() => {
if (activeTooltip && !activeTooltip.matches(':hover')) {
activeTooltip.remove();
activeTooltip = null;
}
}, 200);
});
});
async function showUserStats(element, userId) {
// Remove any existing tooltip
if (activeTooltip) {
activeTooltip.remove();
}
// Create tooltip
const tooltip = document.createElement('div');
tooltip.className = 'user-stats-tooltip';
tooltip.innerHTML = '<div class="loading">Ładowanie...</div>';
element.appendChild(tooltip);
activeTooltip = tooltip;
// Check cache
if (userStatsCache[userId]) {
renderStats(tooltip, userStatsCache[userId]);
return;
}
// Fetch stats
try {
const response = await fetch(`/forum/user/${userId}/stats`);
const data = await response.json();
if (data.success) {
userStatsCache[userId] = data;
renderStats(tooltip, data);
} else {
tooltip.innerHTML = '<div class="loading">Błąd</div>';
}
} catch (e) {
tooltip.innerHTML = '<div class="loading">Błąd</div>';
}
// Close on mouse leave
tooltip.addEventListener('mouseleave', function() {
this.remove();
activeTooltip = null;
});
}
function renderStats(tooltip, data) {
const s = data.stats;
tooltip.innerHTML = `
<div class="stats-header">${data.user_name}</div>
<div class="stats-grid">
<div class="stat-item">
<span>Tematy:</span>
<span class="stat-value">${s.topics}</span>
</div>
<div class="stat-item">
<span>Odpowiedzi:</span>
<span class="stat-value">${s.replies}</span>
</div>
<div class="stat-item">
<span>Rozwiązania:</span>
<span class="stat-value">${s.solutions}</span>
</div>
<div class="stat-item">
<span>Reakcje:</span>
<span class="stat-value">${s.reactions_received}</span>
</div>
</div>
`;
}
// Lightbox functions // Lightbox functions
function openLightbox(src) { function openLightbox(src) {
document.getElementById('lightboxImage').src = src; document.getElementById('lightboxImage').src = src;
@ -1391,6 +1610,30 @@
// USER ACTIONS: Subscribe, Reactions, Edit, Delete, Report // USER ACTIONS: Subscribe, Reactions, Edit, Delete, Report
// ============================================================ // ============================================================
// Quote reply - insert quoted text into reply textarea
function quoteReply(author, content) {
const textarea = document.getElementById('replyContent');
if (!textarea) {
showToast('Temat jest zamknięty', 'warning');
return;
}
// Format quote with author
const quote = `> **${author}** napisał(a):\n> ${content.trim().replace(/\n/g, '\n> ')}\n\n`;
// Insert at cursor position or append
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const currentValue = textarea.value;
textarea.value = currentValue.substring(0, start) + quote + currentValue.substring(end);
textarea.focus();
textarea.selectionStart = textarea.selectionEnd = start + quote.length;
// Scroll to textarea
textarea.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
function toggleSubscribe(topicId) { function toggleSubscribe(topicId) {
const btn = document.getElementById('subscribeBtn'); const btn = document.getElementById('subscribeBtn');
const isSubscribed = btn.classList.contains('subscribed'); const isSubscribed = btn.classList.contains('subscribed');

126
utils/markdown.py Normal file
View File

@ -0,0 +1,126 @@
"""
Simple Markdown Parser for Forum
================================
Converts basic markdown to safe HTML.
Supports: bold, italic, code, links, lists, quotes, @mentions
"""
import re
from markupsafe import Markup, escape
def parse_forum_markdown(text):
"""
Convert markdown text to safe HTML.
Supported syntax:
- **bold** or __bold__
- *italic* or _italic_
- `inline code`
- [link text](url)
- - list items
- > quotes
- @mentions (highlighted)
Args:
text: Raw markdown text
Returns:
Markup object with safe HTML
"""
if not text:
return Markup('')
# Escape HTML first for security
text = str(escape(text))
# Process line by line for block elements
lines = text.split('\n')
result_lines = []
in_list = False
in_quote = False
for line in lines:
stripped = line.strip()
# Quote blocks (> text)
if stripped.startswith('&gt; '): # Escaped >
if not in_quote:
result_lines.append('<blockquote class="forum-quote">')
in_quote = True
result_lines.append(stripped[5:]) # Remove &gt; prefix
continue
elif in_quote:
result_lines.append('</blockquote>')
in_quote = False
# List items (- text)
if stripped.startswith('- '):
if not in_list:
result_lines.append('<ul class="forum-list">')
in_list = True
result_lines.append(f'<li>{stripped[2:]}</li>')
continue
elif in_list:
result_lines.append('</ul>')
in_list = False
result_lines.append(line)
# Close open blocks
if in_list:
result_lines.append('</ul>')
if in_quote:
result_lines.append('</blockquote>')
text = '\n'.join(result_lines)
# Inline formatting (order matters!)
# Code blocks (``` ... ```)
text = re.sub(
r'```(.*?)```',
r'<pre class="forum-code-block"><code>\1</code></pre>',
text,
flags=re.DOTALL
)
# Inline code (`code`)
text = re.sub(r'`([^`]+)`', r'<code class="forum-code">\1</code>', text)
# Bold (**text** or __text__)
text = re.sub(r'\*\*([^*]+)\*\*', r'<strong>\1</strong>', text)
text = re.sub(r'__([^_]+)__', r'<strong>\1</strong>', text)
# Italic (*text* or _text_) - careful not to match bold
text = re.sub(r'(?<!\*)\*([^*]+)\*(?!\*)', r'<em>\1</em>', text)
text = re.sub(r'(?<!_)_([^_]+)_(?!_)', r'<em>\1</em>', text)
# Links [text](url) - only allow http/https
def safe_link(match):
link_text = match.group(1)
url = match.group(2)
if url.startswith(('http://', 'https://', '/')):
return f'<a href="{url}" target="_blank" rel="noopener noreferrer" class="forum-link">{link_text}</a>'
return match.group(0) # Return original if not safe
text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', safe_link, text)
# @mentions - highlight them
text = re.sub(
r'@([\w.\-]+)',
r'<span class="forum-mention">@\1</span>',
text
)
# Convert newlines to <br> (but not inside pre/blockquote)
# Simple approach: just convert \n to <br>
text = text.replace('\n', '<br>\n')
return Markup(text)
def register_markdown_filter(app):
"""Register the markdown filter with Flask app."""
app.jinja_env.filters['forum_markdown'] = parse_forum_markdown

View File

@ -320,3 +320,70 @@ def create_forum_report_notification(admin_user_ids, report_id, content_type, re
related_id=report_id, related_id=report_id,
action_url='/admin/forum/reports' action_url='/admin/forum/reports'
) )
def parse_mentions_and_notify(content, author_id, author_name, topic_id, content_type, content_id):
"""
Parse @mentions in content and send notifications.
Supports formats:
- @jan.kowalski (name with dots)
- @jan_kowalski (name with underscores)
- @jankowalski (name without separators)
Args:
content: Text content to parse
author_id: ID of the content author (won't be notified)
author_name: Name of the author
topic_id: ID of the topic
content_type: 'topic' or 'reply'
content_id: ID of the content
Returns:
List of mentioned user IDs
"""
import re
# Find all @mentions (letters, numbers, dots, underscores, hyphens)
mentions = re.findall(r'@([\w.\-]+)', content)
if not mentions:
return []
db = SessionLocal()
try:
mentioned_user_ids = []
for mention in set(mentions): # Unique mentions
# Try to find user by name (case-insensitive)
mention_lower = mention.lower()
# Try exact name match
user = db.query(User).filter(
User.is_active == True,
User.id != author_id
).filter(
(User.name.ilike(mention)) |
(User.name.ilike(mention.replace('.', ' '))) |
(User.name.ilike(mention.replace('_', ' '))) |
(User.email.ilike(f'{mention}@%'))
).first()
if user:
mentioned_user_ids.append(user.id)
create_notification(
user_id=user.id,
title=f"@{author_name} wspomniał o Tobie",
message=f"Zostałeś wspomniany w {'odpowiedzi' if content_type == 'reply' else 'temacie'} na forum",
notification_type='message',
related_type=f'forum_{content_type}',
related_id=content_id,
action_url=f'/forum/{topic_id}{"#reply-" + str(content_id) if content_type == "reply" else ""}'
)
return mentioned_user_ids
except Exception as e:
logger.error(f"Error parsing mentions: {e}")
return []
finally:
db.close()