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:
parent
f22342ea37
commit
c5f724f954
4
app.py
4
app.py
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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 %}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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
126
utils/markdown.py
Normal 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('> '): # Escaped >
|
||||||
|
if not in_quote:
|
||||||
|
result_lines.append('<blockquote class="forum-quote">')
|
||||||
|
in_quote = True
|
||||||
|
result_lines.append(stripped[5:]) # Remove > 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
|
||||||
@ -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()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user