feat: Forum categories, statuses, and multi-file attachments
- Add category selection (feature_request, bug, question, announcement) - Add status tracking (new, in_progress, resolved, rejected) with admin controls - Add file attachments support (JPG, PNG, GIF up to 5MB) - Multi-file upload (up to 10 files per reply) with drag & drop and paste - New FileUploadService with EXIF stripping for privacy - Admin panel with status statistics and change modal - Grid display for multiple attachments Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
cdc53d9ff3
commit
61e70ad67c
172
app.py
172
app.py
@ -97,6 +97,7 @@ from database import (
|
|||||||
AIAPICostLog,
|
AIAPICostLog,
|
||||||
ForumTopic,
|
ForumTopic,
|
||||||
ForumReply,
|
ForumReply,
|
||||||
|
ForumAttachment,
|
||||||
NordaEvent,
|
NordaEvent,
|
||||||
EventAttendee,
|
EventAttendee,
|
||||||
PrivateMessage,
|
PrivateMessage,
|
||||||
@ -112,6 +113,7 @@ import gemini_service
|
|||||||
from nordabiz_chat import NordaBizChatEngine
|
from nordabiz_chat import NordaBizChatEngine
|
||||||
from search_service import search_companies
|
from search_service import search_companies
|
||||||
import krs_api_service
|
import krs_api_service
|
||||||
|
from file_upload_service import FileUploadService
|
||||||
|
|
||||||
# News service for fetching company news
|
# News service for fetching company news
|
||||||
try:
|
try:
|
||||||
@ -812,14 +814,25 @@ def events():
|
|||||||
@app.route('/forum')
|
@app.route('/forum')
|
||||||
@login_required
|
@login_required
|
||||||
def forum_index():
|
def forum_index():
|
||||||
"""Forum - list of topics"""
|
"""Forum - list of topics with category/status filters"""
|
||||||
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', '')
|
||||||
|
status_filter = request.args.get('status', '')
|
||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
# Get topics ordered by pinned first, then by last activity
|
# Build query with optional filters
|
||||||
query = db.query(ForumTopic).order_by(
|
query = db.query(ForumTopic)
|
||||||
|
|
||||||
|
if category_filter and category_filter in ForumTopic.CATEGORIES:
|
||||||
|
query = query.filter(ForumTopic.category == category_filter)
|
||||||
|
|
||||||
|
if status_filter and status_filter in ForumTopic.STATUSES:
|
||||||
|
query = query.filter(ForumTopic.status == status_filter)
|
||||||
|
|
||||||
|
# Order by pinned first, then by last activity
|
||||||
|
query = query.order_by(
|
||||||
ForumTopic.is_pinned.desc(),
|
ForumTopic.is_pinned.desc(),
|
||||||
ForumTopic.updated_at.desc()
|
ForumTopic.updated_at.desc()
|
||||||
)
|
)
|
||||||
@ -833,7 +846,13 @@ def forum_index():
|
|||||||
page=page,
|
page=page,
|
||||||
per_page=per_page,
|
per_page=per_page,
|
||||||
total_topics=total_topics,
|
total_topics=total_topics,
|
||||||
total_pages=(total_topics + per_page - 1) // per_page
|
total_pages=(total_topics + per_page - 1) // per_page,
|
||||||
|
category_filter=category_filter,
|
||||||
|
status_filter=status_filter,
|
||||||
|
categories=ForumTopic.CATEGORIES,
|
||||||
|
statuses=ForumTopic.STATUSES,
|
||||||
|
category_labels=ForumTopic.CATEGORY_LABELS,
|
||||||
|
status_labels=ForumTopic.STATUS_LABELS
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
@ -842,36 +861,70 @@ def forum_index():
|
|||||||
@app.route('/forum/nowy', methods=['GET', 'POST'])
|
@app.route('/forum/nowy', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def forum_new_topic():
|
def forum_new_topic():
|
||||||
"""Create new forum topic"""
|
"""Create new forum topic with category and attachments"""
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
title = sanitize_input(request.form.get('title', ''), 255)
|
title = sanitize_input(request.form.get('title', ''), 255)
|
||||||
content = request.form.get('content', '').strip()
|
content = request.form.get('content', '').strip()
|
||||||
|
category = request.form.get('category', 'question')
|
||||||
|
|
||||||
|
# Validate category
|
||||||
|
if category not in ForumTopic.CATEGORIES:
|
||||||
|
category = 'question'
|
||||||
|
|
||||||
if not title or len(title) < 5:
|
if not title or len(title) < 5:
|
||||||
flash('Tytuł musi mieć co najmniej 5 znaków.', 'error')
|
flash('Tytuł musi mieć co najmniej 5 znaków.', 'error')
|
||||||
return render_template('forum/new_topic.html')
|
return render_template('forum/new_topic.html',
|
||||||
|
categories=ForumTopic.CATEGORIES,
|
||||||
|
category_labels=ForumTopic.CATEGORY_LABELS)
|
||||||
|
|
||||||
if not content or len(content) < 10:
|
if not content or len(content) < 10:
|
||||||
flash('Treść musi mieć co najmniej 10 znaków.', 'error')
|
flash('Treść musi mieć co najmniej 10 znaków.', 'error')
|
||||||
return render_template('forum/new_topic.html')
|
return render_template('forum/new_topic.html',
|
||||||
|
categories=ForumTopic.CATEGORIES,
|
||||||
|
category_labels=ForumTopic.CATEGORY_LABELS)
|
||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
topic = ForumTopic(
|
topic = ForumTopic(
|
||||||
title=title,
|
title=title,
|
||||||
content=content,
|
content=content,
|
||||||
author_id=current_user.id
|
author_id=current_user.id,
|
||||||
|
category=category
|
||||||
)
|
)
|
||||||
db.add(topic)
|
db.add(topic)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(topic)
|
db.refresh(topic)
|
||||||
|
|
||||||
|
# Handle file upload
|
||||||
|
if 'attachment' in request.files:
|
||||||
|
file = request.files['attachment']
|
||||||
|
if file and file.filename:
|
||||||
|
is_valid, error_msg = FileUploadService.validate_file(file)
|
||||||
|
if is_valid:
|
||||||
|
stored_filename, rel_path, file_size, mime_type = FileUploadService.save_file(file, 'topic')
|
||||||
|
attachment = ForumAttachment(
|
||||||
|
attachment_type='topic',
|
||||||
|
topic_id=topic.id,
|
||||||
|
original_filename=file.filename,
|
||||||
|
stored_filename=stored_filename,
|
||||||
|
file_extension=stored_filename.rsplit('.', 1)[-1],
|
||||||
|
file_size=file_size,
|
||||||
|
mime_type=mime_type,
|
||||||
|
uploaded_by=current_user.id
|
||||||
|
)
|
||||||
|
db.add(attachment)
|
||||||
|
db.commit()
|
||||||
|
else:
|
||||||
|
flash(f'Załącznik: {error_msg}', 'warning')
|
||||||
|
|
||||||
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:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
return render_template('forum/new_topic.html')
|
return render_template('forum/new_topic.html',
|
||||||
|
categories=ForumTopic.CATEGORIES,
|
||||||
|
category_labels=ForumTopic.CATEGORY_LABELS)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/forum/<int:topic_id>')
|
@app.route('/forum/<int:topic_id>')
|
||||||
@ -890,7 +943,10 @@ def forum_topic(topic_id):
|
|||||||
topic.views_count += 1
|
topic.views_count += 1
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return render_template('forum/topic.html', topic=topic)
|
return render_template('forum/topic.html',
|
||||||
|
topic=topic,
|
||||||
|
category_labels=ForumTopic.CATEGORY_LABELS,
|
||||||
|
status_labels=ForumTopic.STATUS_LABELS)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@ -898,7 +954,7 @@ def forum_topic(topic_id):
|
|||||||
@app.route('/forum/<int:topic_id>/odpowiedz', methods=['POST'])
|
@app.route('/forum/<int:topic_id>/odpowiedz', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def forum_reply(topic_id):
|
def forum_reply(topic_id):
|
||||||
"""Add reply to forum topic"""
|
"""Add reply to forum topic with optional attachment"""
|
||||||
content = request.form.get('content', '').strip()
|
content = request.form.get('content', '').strip()
|
||||||
|
|
||||||
if not content or len(content) < 3:
|
if not content or len(content) < 3:
|
||||||
@ -923,6 +979,44 @@ def forum_reply(topic_id):
|
|||||||
content=content
|
content=content
|
||||||
)
|
)
|
||||||
db.add(reply)
|
db.add(reply)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(reply)
|
||||||
|
|
||||||
|
# Handle multiple file uploads (max 10)
|
||||||
|
MAX_ATTACHMENTS = 10
|
||||||
|
files = request.files.getlist('attachments[]')
|
||||||
|
if not files:
|
||||||
|
# Fallback for single file upload (backward compatibility)
|
||||||
|
files = request.files.getlist('attachment')
|
||||||
|
|
||||||
|
uploaded_count = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for file in files[:MAX_ATTACHMENTS]:
|
||||||
|
if file and file.filename:
|
||||||
|
is_valid, error_msg = FileUploadService.validate_file(file)
|
||||||
|
if is_valid:
|
||||||
|
stored_filename, rel_path, file_size, mime_type = FileUploadService.save_file(file, 'reply')
|
||||||
|
attachment = ForumAttachment(
|
||||||
|
attachment_type='reply',
|
||||||
|
reply_id=reply.id,
|
||||||
|
original_filename=file.filename,
|
||||||
|
stored_filename=stored_filename,
|
||||||
|
file_extension=stored_filename.rsplit('.', 1)[-1],
|
||||||
|
file_size=file_size,
|
||||||
|
mime_type=mime_type,
|
||||||
|
uploaded_by=current_user.id
|
||||||
|
)
|
||||||
|
db.add(attachment)
|
||||||
|
uploaded_count += 1
|
||||||
|
else:
|
||||||
|
errors.append(f'{file.filename}: {error_msg}')
|
||||||
|
|
||||||
|
if uploaded_count > 0:
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
flash(f'Niektóre załączniki nie zostały dodane: {"; ".join(errors)}', 'warning')
|
||||||
|
|
||||||
# Update topic updated_at
|
# Update topic updated_at
|
||||||
topic.updated_at = datetime.now()
|
topic.updated_at = datetime.now()
|
||||||
@ -964,6 +1058,15 @@ def admin_forum():
|
|||||||
pinned_count = sum(1 for t in topics if t.is_pinned)
|
pinned_count = sum(1 for t in topics if t.is_pinned)
|
||||||
locked_count = sum(1 for t in topics if t.is_locked)
|
locked_count = sum(1 for t in topics if t.is_locked)
|
||||||
|
|
||||||
|
# Category and status stats
|
||||||
|
category_counts = {}
|
||||||
|
status_counts = {}
|
||||||
|
for t in topics:
|
||||||
|
cat = t.category or 'question'
|
||||||
|
status = t.status or 'new'
|
||||||
|
category_counts[cat] = category_counts.get(cat, 0) + 1
|
||||||
|
status_counts[status] = status_counts.get(status, 0) + 1
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'admin/forum.html',
|
'admin/forum.html',
|
||||||
topics=topics,
|
topics=topics,
|
||||||
@ -971,7 +1074,13 @@ def admin_forum():
|
|||||||
total_topics=total_topics,
|
total_topics=total_topics,
|
||||||
total_replies=total_replies,
|
total_replies=total_replies,
|
||||||
pinned_count=pinned_count,
|
pinned_count=pinned_count,
|
||||||
locked_count=locked_count
|
locked_count=locked_count,
|
||||||
|
category_counts=category_counts,
|
||||||
|
status_counts=status_counts,
|
||||||
|
categories=ForumTopic.CATEGORIES,
|
||||||
|
statuses=ForumTopic.STATUSES,
|
||||||
|
category_labels=ForumTopic.CATEGORY_LABELS,
|
||||||
|
status_labels=ForumTopic.STATUS_LABELS
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
@ -1081,6 +1190,45 @@ def admin_forum_delete_reply(reply_id):
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/admin/forum/topic/<int:topic_id>/status', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def admin_forum_change_status(topic_id):
|
||||||
|
"""Change topic status (admin only)"""
|
||||||
|
if not current_user.is_admin:
|
||||||
|
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
new_status = data.get('status')
|
||||||
|
note = data.get('note', '').strip()
|
||||||
|
|
||||||
|
if not new_status or new_status not in ForumTopic.STATUSES:
|
||||||
|
return jsonify({'success': False, 'error': 'Nieprawidłowy status'}), 400
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
topic = db.query(ForumTopic).filter(ForumTopic.id == topic_id).first()
|
||||||
|
if not topic:
|
||||||
|
return jsonify({'success': False, 'error': 'Temat nie istnieje'}), 404
|
||||||
|
|
||||||
|
old_status = topic.status
|
||||||
|
topic.status = new_status
|
||||||
|
topic.status_changed_by = current_user.id
|
||||||
|
topic.status_changed_at = datetime.now()
|
||||||
|
if note:
|
||||||
|
topic.status_note = note
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Admin {current_user.email} changed topic #{topic_id} status: {old_status} -> {new_status}")
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'status': new_status,
|
||||||
|
'status_label': ForumTopic.STATUS_LABELS.get(new_status, new_status),
|
||||||
|
'message': f"Status zmieniony na: {ForumTopic.STATUS_LABELS.get(new_status, new_status)}"
|
||||||
|
})
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# RECOMMENDATIONS ADMIN ROUTES
|
# RECOMMENDATIONS ADMIN ROUTES
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
100
database.py
100
database.py
@ -153,7 +153,7 @@ class User(Base, UserMixin):
|
|||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
conversations = relationship('AIChatConversation', back_populates='user', cascade='all, delete-orphan')
|
conversations = relationship('AIChatConversation', back_populates='user', cascade='all, delete-orphan')
|
||||||
forum_topics = relationship('ForumTopic', back_populates='author', cascade='all, delete-orphan')
|
forum_topics = relationship('ForumTopic', back_populates='author', cascade='all, delete-orphan', primaryjoin='User.id == ForumTopic.author_id')
|
||||||
forum_replies = relationship('ForumReply', back_populates='author', cascade='all, delete-orphan')
|
forum_replies = relationship('ForumReply', back_populates='author', cascade='all, delete-orphan')
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
@ -791,7 +791,14 @@ class ForumTopic(Base):
|
|||||||
content = Column(Text, nullable=False)
|
content = Column(Text, nullable=False)
|
||||||
author_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
author_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||||||
|
|
||||||
# Status
|
# Category and Status (for feedback tracking)
|
||||||
|
category = Column(String(50), default='question') # feature_request, bug, question, announcement
|
||||||
|
status = Column(String(50), default='new') # new, in_progress, resolved, rejected
|
||||||
|
status_changed_by = Column(Integer, ForeignKey('users.id'))
|
||||||
|
status_changed_at = Column(DateTime)
|
||||||
|
status_note = Column(Text)
|
||||||
|
|
||||||
|
# Moderation flags
|
||||||
is_pinned = Column(Boolean, default=False)
|
is_pinned = Column(Boolean, default=False)
|
||||||
is_locked = Column(Boolean, default=False)
|
is_locked = Column(Boolean, default=False)
|
||||||
views_count = Column(Integer, default=0)
|
views_count = Column(Integer, default=0)
|
||||||
@ -800,9 +807,30 @@ class ForumTopic(Base):
|
|||||||
created_at = Column(DateTime, default=datetime.now)
|
created_at = Column(DateTime, default=datetime.now)
|
||||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||||
|
|
||||||
|
# Constants for validation
|
||||||
|
CATEGORIES = ['feature_request', 'bug', 'question', 'announcement']
|
||||||
|
STATUSES = ['new', 'in_progress', 'resolved', 'rejected']
|
||||||
|
|
||||||
|
CATEGORY_LABELS = {
|
||||||
|
'feature_request': 'Propozycja funkcji',
|
||||||
|
'bug': 'Błąd',
|
||||||
|
'question': 'Pytanie',
|
||||||
|
'announcement': 'Ogłoszenie'
|
||||||
|
}
|
||||||
|
|
||||||
|
STATUS_LABELS = {
|
||||||
|
'new': 'Nowy',
|
||||||
|
'in_progress': 'W realizacji',
|
||||||
|
'resolved': 'Rozwiązany',
|
||||||
|
'rejected': 'Odrzucony'
|
||||||
|
}
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
author = relationship('User', back_populates='forum_topics')
|
author = relationship('User', foreign_keys=[author_id], back_populates='forum_topics')
|
||||||
|
status_changer = relationship('User', foreign_keys=[status_changed_by])
|
||||||
replies = relationship('ForumReply', back_populates='topic', cascade='all, delete-orphan', order_by='ForumReply.created_at')
|
replies = relationship('ForumReply', back_populates='topic', cascade='all, delete-orphan', order_by='ForumReply.created_at')
|
||||||
|
attachments = relationship('ForumAttachment', back_populates='topic', cascade='all, delete-orphan',
|
||||||
|
primaryjoin="and_(ForumAttachment.topic_id==ForumTopic.id, ForumAttachment.attachment_type=='topic')")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def reply_count(self):
|
def reply_count(self):
|
||||||
@ -814,6 +842,14 @@ class ForumTopic(Base):
|
|||||||
return max(r.created_at for r in self.replies)
|
return max(r.created_at for r in self.replies)
|
||||||
return self.created_at
|
return self.created_at
|
||||||
|
|
||||||
|
@property
|
||||||
|
def category_label(self):
|
||||||
|
return self.CATEGORY_LABELS.get(self.category, self.category)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status_label(self):
|
||||||
|
return self.STATUS_LABELS.get(self.status, self.status)
|
||||||
|
|
||||||
|
|
||||||
class ForumReply(Base):
|
class ForumReply(Base):
|
||||||
"""Forum replies to topics"""
|
"""Forum replies to topics"""
|
||||||
@ -831,6 +867,64 @@ class ForumReply(Base):
|
|||||||
# Relationships
|
# Relationships
|
||||||
topic = relationship('ForumTopic', back_populates='replies')
|
topic = relationship('ForumTopic', back_populates='replies')
|
||||||
author = relationship('User', back_populates='forum_replies')
|
author = relationship('User', back_populates='forum_replies')
|
||||||
|
attachments = relationship('ForumAttachment', back_populates='reply', cascade='all, delete-orphan',
|
||||||
|
primaryjoin="and_(ForumAttachment.reply_id==ForumReply.id, ForumAttachment.attachment_type=='reply')")
|
||||||
|
|
||||||
|
|
||||||
|
class ForumAttachment(Base):
|
||||||
|
"""Forum file attachments for topics and replies"""
|
||||||
|
__tablename__ = 'forum_attachments'
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
|
||||||
|
# Polymorphic relationship (topic or reply)
|
||||||
|
attachment_type = Column(String(20), nullable=False) # 'topic' or 'reply'
|
||||||
|
topic_id = Column(Integer, ForeignKey('forum_topics.id', ondelete='CASCADE'))
|
||||||
|
reply_id = Column(Integer, ForeignKey('forum_replies.id', ondelete='CASCADE'))
|
||||||
|
|
||||||
|
# File metadata
|
||||||
|
original_filename = Column(String(255), nullable=False)
|
||||||
|
stored_filename = Column(String(255), nullable=False, unique=True)
|
||||||
|
file_extension = Column(String(10), nullable=False)
|
||||||
|
file_size = Column(Integer, nullable=False) # in bytes
|
||||||
|
mime_type = Column(String(100), nullable=False)
|
||||||
|
|
||||||
|
# Uploader
|
||||||
|
uploaded_by = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at = Column(DateTime, default=datetime.now)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
topic = relationship('ForumTopic', back_populates='attachments', foreign_keys=[topic_id])
|
||||||
|
reply = relationship('ForumReply', back_populates='attachments', foreign_keys=[reply_id])
|
||||||
|
uploader = relationship('User')
|
||||||
|
|
||||||
|
# Allowed file types
|
||||||
|
ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif'}
|
||||||
|
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB
|
||||||
|
|
||||||
|
@property
|
||||||
|
def url(self):
|
||||||
|
"""Get the URL to serve this file"""
|
||||||
|
date = self.created_at or datetime.now()
|
||||||
|
subdir = 'topics' if self.attachment_type == 'topic' else 'replies'
|
||||||
|
return f"/static/uploads/forum/{subdir}/{date.year}/{date.month:02d}/{self.stored_filename}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_image(self):
|
||||||
|
"""Check if this is an image file"""
|
||||||
|
return self.mime_type.startswith('image/')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def size_display(self):
|
||||||
|
"""Human-readable file size"""
|
||||||
|
if self.file_size < 1024:
|
||||||
|
return f"{self.file_size} B"
|
||||||
|
elif self.file_size < 1024 * 1024:
|
||||||
|
return f"{self.file_size / 1024:.1f} KB"
|
||||||
|
else:
|
||||||
|
return f"{self.file_size / (1024 * 1024):.1f} MB"
|
||||||
|
|
||||||
|
|
||||||
class AIAPICostLog(Base):
|
class AIAPICostLog(Base):
|
||||||
|
|||||||
90
database/forum_categories_attachments.sql
Normal file
90
database/forum_categories_attachments.sql
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
-- Migration: Forum Categories, Statuses, and Attachments
|
||||||
|
-- Date: 2026-01-10
|
||||||
|
-- Description: Extends forum with categories, status tracking, and file attachments
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- PHASE 1: Categories and Statuses for Topics
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Add category column (default: question)
|
||||||
|
ALTER TABLE forum_topics
|
||||||
|
ADD COLUMN IF NOT EXISTS category VARCHAR(50) DEFAULT 'question';
|
||||||
|
|
||||||
|
-- Add status column (default: new)
|
||||||
|
ALTER TABLE forum_topics
|
||||||
|
ADD COLUMN IF NOT EXISTS status VARCHAR(50) DEFAULT 'new';
|
||||||
|
|
||||||
|
-- Add admin tracking for status changes
|
||||||
|
ALTER TABLE forum_topics
|
||||||
|
ADD COLUMN IF NOT EXISTS status_changed_by INTEGER REFERENCES users(id);
|
||||||
|
|
||||||
|
ALTER TABLE forum_topics
|
||||||
|
ADD COLUMN IF NOT EXISTS status_changed_at TIMESTAMP;
|
||||||
|
|
||||||
|
ALTER TABLE forum_topics
|
||||||
|
ADD COLUMN IF NOT EXISTS status_note TEXT;
|
||||||
|
|
||||||
|
-- Create indexes for filtering
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_forum_topics_category ON forum_topics(category);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_forum_topics_status ON forum_topics(status);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- PHASE 2: File Attachments
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Create forum_attachments table
|
||||||
|
CREATE TABLE IF NOT EXISTS forum_attachments (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
|
||||||
|
-- Polymorphic relationship (topic or reply)
|
||||||
|
attachment_type VARCHAR(20) NOT NULL, -- 'topic' or 'reply'
|
||||||
|
topic_id INTEGER REFERENCES forum_topics(id) ON DELETE CASCADE,
|
||||||
|
reply_id INTEGER REFERENCES forum_replies(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- File metadata
|
||||||
|
original_filename VARCHAR(255) NOT NULL,
|
||||||
|
stored_filename VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
file_extension VARCHAR(10) NOT NULL,
|
||||||
|
file_size INTEGER NOT NULL, -- in bytes
|
||||||
|
mime_type VARCHAR(100) NOT NULL,
|
||||||
|
|
||||||
|
-- Uploader
|
||||||
|
uploaded_by INTEGER REFERENCES users(id) NOT NULL,
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- Constraints
|
||||||
|
CONSTRAINT chk_attachment_type CHECK (attachment_type IN ('topic', 'reply')),
|
||||||
|
CONSTRAINT chk_file_size CHECK (file_size <= 5242880), -- 5MB max
|
||||||
|
CONSTRAINT chk_attachment_target CHECK (
|
||||||
|
(attachment_type = 'topic' AND topic_id IS NOT NULL AND reply_id IS NULL) OR
|
||||||
|
(attachment_type = 'reply' AND reply_id IS NOT NULL AND topic_id IS NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for attachments
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_forum_attachments_topic ON forum_attachments(topic_id) WHERE topic_id IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_forum_attachments_reply ON forum_attachments(reply_id) WHERE reply_id IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_forum_attachments_uploaded_by ON forum_attachments(uploaded_by);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- PERMISSIONS
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Grant permissions to app user
|
||||||
|
GRANT ALL ON TABLE forum_attachments TO nordabiz_app;
|
||||||
|
GRANT USAGE, SELECT ON SEQUENCE forum_attachments_id_seq TO nordabiz_app;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- VERIFICATION
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Check columns were added
|
||||||
|
SELECT column_name, data_type, column_default
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'forum_topics'
|
||||||
|
AND column_name IN ('category', 'status', 'status_changed_by', 'status_changed_at', 'status_note');
|
||||||
|
|
||||||
|
-- Check attachments table exists
|
||||||
|
SELECT table_name FROM information_schema.tables WHERE table_name = 'forum_attachments';
|
||||||
288
file_upload_service.py
Normal file
288
file_upload_service.py
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
"""
|
||||||
|
Forum File Upload Service
|
||||||
|
=========================
|
||||||
|
|
||||||
|
Secure file upload handling for forum attachments.
|
||||||
|
Supports JPG, PNG, GIF images up to 5MB.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- File type validation (magic bytes + extension)
|
||||||
|
- Size limits
|
||||||
|
- EXIF data stripping for privacy
|
||||||
|
- UUID-based filenames for security
|
||||||
|
- Date-organized storage structure
|
||||||
|
|
||||||
|
Author: Norda Biznes Development Team
|
||||||
|
Created: 2026-01-10
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Tuple, Optional
|
||||||
|
from werkzeug.datastructures import FileStorage
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif'}
|
||||||
|
ALLOWED_MIME_TYPES = {'image/jpeg', 'image/png', 'image/gif'}
|
||||||
|
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB
|
||||||
|
MAX_IMAGE_DIMENSIONS = (4096, 4096) # Max 4K resolution
|
||||||
|
UPLOAD_BASE_PATH = 'static/uploads/forum'
|
||||||
|
|
||||||
|
# Magic bytes for image validation
|
||||||
|
IMAGE_SIGNATURES = {
|
||||||
|
b'\xff\xd8\xff': 'jpg', # JPEG
|
||||||
|
b'\x89PNG\r\n\x1a\n': 'png', # PNG
|
||||||
|
b'GIF87a': 'gif', # GIF87a
|
||||||
|
b'GIF89a': 'gif', # GIF89a
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FileUploadService:
|
||||||
|
"""Secure file upload service for forum attachments"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def validate_file(file: FileStorage) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Validate uploaded file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file: Werkzeug FileStorage object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid, error_message)
|
||||||
|
"""
|
||||||
|
# Check if file exists
|
||||||
|
if not file or file.filename == '':
|
||||||
|
return False, 'Nie wybrano pliku'
|
||||||
|
|
||||||
|
# Check extension
|
||||||
|
ext = file.filename.rsplit('.', 1)[-1].lower() if '.' in file.filename else ''
|
||||||
|
if ext not in ALLOWED_EXTENSIONS:
|
||||||
|
return False, f'Niedozwolony format pliku. Dozwolone: {", ".join(sorted(ALLOWED_EXTENSIONS))}'
|
||||||
|
|
||||||
|
# Check file size
|
||||||
|
file.seek(0, 2) # Seek to end
|
||||||
|
size = file.tell()
|
||||||
|
file.seek(0) # Reset to beginning
|
||||||
|
|
||||||
|
if size > MAX_FILE_SIZE:
|
||||||
|
return False, f'Plik jest za duży (max {MAX_FILE_SIZE // 1024 // 1024}MB)'
|
||||||
|
|
||||||
|
if size == 0:
|
||||||
|
return False, 'Plik jest pusty'
|
||||||
|
|
||||||
|
# Verify magic bytes (actual file type)
|
||||||
|
header = file.read(16)
|
||||||
|
file.seek(0)
|
||||||
|
|
||||||
|
detected_type = None
|
||||||
|
for signature, file_type in IMAGE_SIGNATURES.items():
|
||||||
|
if header.startswith(signature):
|
||||||
|
detected_type = file_type
|
||||||
|
break
|
||||||
|
|
||||||
|
if not detected_type:
|
||||||
|
return False, 'Plik nie jest prawidłowym obrazem'
|
||||||
|
|
||||||
|
# Check if extension matches detected type
|
||||||
|
if ext == 'jpg':
|
||||||
|
ext = 'jpeg' # Normalize
|
||||||
|
if detected_type == 'jpg':
|
||||||
|
detected_type = 'jpeg'
|
||||||
|
|
||||||
|
if detected_type not in (ext, 'jpeg' if ext == 'jpg' else ext):
|
||||||
|
# Allow jpg/jpeg mismatch
|
||||||
|
if not (detected_type == 'jpeg' and ext in ('jpg', 'jpeg')):
|
||||||
|
return False, f'Rozszerzenie pliku ({ext}) nie odpowiada zawartości ({detected_type})'
|
||||||
|
|
||||||
|
# Validate image dimensions using PIL (if available)
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
img = Image.open(file)
|
||||||
|
width, height = img.size
|
||||||
|
file.seek(0)
|
||||||
|
|
||||||
|
if width > MAX_IMAGE_DIMENSIONS[0] or height > MAX_IMAGE_DIMENSIONS[1]:
|
||||||
|
return False, f'Obraz jest za duży (max {MAX_IMAGE_DIMENSIONS[0]}x{MAX_IMAGE_DIMENSIONS[1]}px)'
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
# PIL not available, skip dimension check
|
||||||
|
logger.warning("PIL not available, skipping image dimension validation")
|
||||||
|
except Exception as e:
|
||||||
|
file.seek(0)
|
||||||
|
return False, f'Nie można odczytać obrazu: {str(e)}'
|
||||||
|
|
||||||
|
return True, ''
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_stored_filename(original_filename: str) -> str:
|
||||||
|
"""
|
||||||
|
Generate secure UUID-based filename preserving extension.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
original_filename: Original filename from upload
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
UUID-based filename with original extension
|
||||||
|
"""
|
||||||
|
ext = original_filename.rsplit('.', 1)[-1].lower() if '.' in original_filename else 'bin'
|
||||||
|
if ext == 'jpeg':
|
||||||
|
ext = 'jpg' # Normalize to jpg
|
||||||
|
return f"{uuid.uuid4()}.{ext}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_upload_path(attachment_type: str) -> str:
|
||||||
|
"""
|
||||||
|
Get upload directory path with date-based organization.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
attachment_type: 'topic' or 'reply'
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Full path to upload directory
|
||||||
|
"""
|
||||||
|
now = datetime.now()
|
||||||
|
subdir = 'topics' if attachment_type == 'topic' else 'replies'
|
||||||
|
path = os.path.join(UPLOAD_BASE_PATH, subdir, str(now.year), f"{now.month:02d}")
|
||||||
|
os.makedirs(path, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def save_file(file: FileStorage, attachment_type: str) -> Tuple[str, str, int, str]:
|
||||||
|
"""
|
||||||
|
Save file securely with EXIF stripping.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file: Werkzeug FileStorage object
|
||||||
|
attachment_type: 'topic' or 'reply'
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (stored_filename, relative_path, file_size, mime_type)
|
||||||
|
"""
|
||||||
|
stored_filename = FileUploadService.generate_stored_filename(file.filename)
|
||||||
|
upload_dir = FileUploadService.get_upload_path(attachment_type)
|
||||||
|
file_path = os.path.join(upload_dir, stored_filename)
|
||||||
|
|
||||||
|
# Determine mime type
|
||||||
|
ext = stored_filename.rsplit('.', 1)[-1].lower()
|
||||||
|
mime_types = {
|
||||||
|
'jpg': 'image/jpeg',
|
||||||
|
'jpeg': 'image/jpeg',
|
||||||
|
'png': 'image/png',
|
||||||
|
'gif': 'image/gif'
|
||||||
|
}
|
||||||
|
mime_type = mime_types.get(ext, 'application/octet-stream')
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
# Open and process image
|
||||||
|
img = Image.open(file)
|
||||||
|
|
||||||
|
# For GIF, preserve animation
|
||||||
|
if ext == 'gif' and getattr(img, 'is_animated', False):
|
||||||
|
# Save animated GIF without modification
|
||||||
|
file.seek(0)
|
||||||
|
file.save(file_path)
|
||||||
|
else:
|
||||||
|
# Strip EXIF data by creating new image
|
||||||
|
if img.mode in ('RGBA', 'LA', 'P'):
|
||||||
|
# Keep transparency for PNG
|
||||||
|
clean_img = Image.new(img.mode, img.size)
|
||||||
|
clean_img.putdata(list(img.getdata()))
|
||||||
|
else:
|
||||||
|
# Convert to RGB for JPEG
|
||||||
|
if img.mode != 'RGB':
|
||||||
|
img = img.convert('RGB')
|
||||||
|
clean_img = Image.new('RGB', img.size)
|
||||||
|
clean_img.putdata(list(img.getdata()))
|
||||||
|
|
||||||
|
# Save with optimization
|
||||||
|
save_kwargs = {'optimize': True}
|
||||||
|
if ext in ('jpg', 'jpeg'):
|
||||||
|
save_kwargs['quality'] = 85
|
||||||
|
elif ext == 'png':
|
||||||
|
save_kwargs['compress_level'] = 6
|
||||||
|
|
||||||
|
clean_img.save(file_path, **save_kwargs)
|
||||||
|
|
||||||
|
file_size = os.path.getsize(file_path)
|
||||||
|
relative_path = os.path.relpath(file_path, 'static')
|
||||||
|
|
||||||
|
logger.info(f"Saved forum attachment: {stored_filename} ({file_size} bytes)")
|
||||||
|
return stored_filename, relative_path, file_size, mime_type
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
# PIL not available, save without processing
|
||||||
|
logger.warning("PIL not available, saving file without EXIF stripping")
|
||||||
|
file.seek(0)
|
||||||
|
file.save(file_path)
|
||||||
|
file_size = os.path.getsize(file_path)
|
||||||
|
relative_path = os.path.relpath(file_path, 'static')
|
||||||
|
return stored_filename, relative_path, file_size, mime_type
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def delete_file(stored_filename: str, attachment_type: str, created_at: Optional[datetime] = None) -> bool:
|
||||||
|
"""
|
||||||
|
Delete file from storage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stored_filename: UUID-based filename
|
||||||
|
attachment_type: 'topic' or 'reply'
|
||||||
|
created_at: Creation timestamp to determine path
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted, False otherwise
|
||||||
|
"""
|
||||||
|
subdir = 'topics' if attachment_type == 'topic' else 'replies'
|
||||||
|
|
||||||
|
if created_at:
|
||||||
|
# Try exact path first
|
||||||
|
path = os.path.join(
|
||||||
|
UPLOAD_BASE_PATH, subdir,
|
||||||
|
str(created_at.year), f"{created_at.month:02d}",
|
||||||
|
stored_filename
|
||||||
|
)
|
||||||
|
if os.path.exists(path):
|
||||||
|
try:
|
||||||
|
os.remove(path)
|
||||||
|
logger.info(f"Deleted forum attachment: {stored_filename}")
|
||||||
|
return True
|
||||||
|
except OSError as e:
|
||||||
|
logger.error(f"Failed to delete {stored_filename}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Search in all date directories
|
||||||
|
base_path = os.path.join(UPLOAD_BASE_PATH, subdir)
|
||||||
|
for root, dirs, files in os.walk(base_path):
|
||||||
|
if stored_filename in files:
|
||||||
|
try:
|
||||||
|
os.remove(os.path.join(root, stored_filename))
|
||||||
|
logger.info(f"Deleted forum attachment: {stored_filename}")
|
||||||
|
return True
|
||||||
|
except OSError as e:
|
||||||
|
logger.error(f"Failed to delete {stored_filename}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.warning(f"Attachment not found for deletion: {stored_filename}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_file_url(stored_filename: str, attachment_type: str, created_at: datetime) -> str:
|
||||||
|
"""
|
||||||
|
Get URL for serving the file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stored_filename: UUID-based filename
|
||||||
|
attachment_type: 'topic' or 'reply'
|
||||||
|
created_at: Creation timestamp
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
URL path to the file
|
||||||
|
"""
|
||||||
|
subdir = 'topics' if attachment_type == 'topic' else 'replies'
|
||||||
|
return f"/static/uploads/forum/{subdir}/{created_at.year}/{created_at.month:02d}/{stored_filename}"
|
||||||
@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
.stats-grid {
|
.stats-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||||
gap: var(--spacing-lg);
|
gap: var(--spacing-lg);
|
||||||
margin-bottom: var(--spacing-2xl);
|
margin-bottom: var(--spacing-2xl);
|
||||||
}
|
}
|
||||||
@ -40,6 +40,16 @@
|
|||||||
margin-top: var(--spacing-xs);
|
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 {
|
.section {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
padding: var(--spacing-xl);
|
padding: var(--spacing-xl);
|
||||||
@ -105,7 +115,6 @@
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-pinned {
|
.badge-pinned {
|
||||||
@ -118,6 +127,65 @@
|
|||||||
color: white;
|
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 {
|
.action-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--spacing-xs);
|
gap: var(--spacing-xs);
|
||||||
@ -193,6 +261,75 @@
|
|||||||
color: var(--text-secondary);
|
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) {
|
@media (max-width: 768px) {
|
||||||
.topics-table {
|
.topics-table {
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
@ -200,8 +337,8 @@
|
|||||||
|
|
||||||
.topics-table th:nth-child(3),
|
.topics-table th:nth-child(3),
|
||||||
.topics-table td:nth-child(3),
|
.topics-table td:nth-child(3),
|
||||||
.topics-table th:nth-child(4),
|
.topics-table th:nth-child(5),
|
||||||
.topics-table td:nth-child(4) {
|
.topics-table td:nth-child(5) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -234,6 +371,46 @@
|
|||||||
</div>
|
</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 -->
|
<!-- Topics Section -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>Tematy</h2>
|
<h2>Tematy</h2>
|
||||||
@ -242,10 +419,10 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Tytul</th>
|
<th>Tytul</th>
|
||||||
|
<th>Kategoria</th>
|
||||||
<th>Autor</th>
|
<th>Autor</th>
|
||||||
<th>Odpowiedzi</th>
|
|
||||||
<th>Data</th>
|
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
|
<th>Data</th>
|
||||||
<th>Akcje</th>
|
<th>Akcje</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -256,11 +433,6 @@
|
|||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
|
||||||
<td class="topic-meta">{{ topic.author.name or topic.author.email.split('@')[0] }}</td>
|
|
||||||
<td class="topic-meta">{{ topic.reply_count }}</td>
|
|
||||||
<td class="topic-meta">{{ topic.created_at.strftime('%d.%m.%Y') }}</td>
|
|
||||||
<td>
|
|
||||||
{% if topic.is_pinned %}
|
{% if topic.is_pinned %}
|
||||||
<span class="badge badge-pinned">Przypiety</span>
|
<span class="badge badge-pinned">Przypiety</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -268,6 +440,20 @@
|
|||||||
<span class="badge badge-locked">Zamkniety</span>
|
<span class="badge badge-locked">Zamkniety</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</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>
|
<td>
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<button class="btn-icon {% if topic.is_pinned %}active{% endif %}"
|
<button class="btn-icon {% if topic.is_pinned %}active{% endif %}"
|
||||||
@ -338,16 +524,103 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
const csrfToken = '{{ csrf_token() }}';
|
const csrfToken = '{{ csrf_token() }}';
|
||||||
|
let currentTopicId = null;
|
||||||
|
|
||||||
function showMessage(message, type) {
|
function showMessage(message, type) {
|
||||||
// Simple alert for now - could be improved with toast notifications
|
|
||||||
alert(message);
|
alert(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
async function togglePin(topicId) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/admin/forum/topic/${topicId}/pin`, {
|
const response = await fetch(`/admin/forum/topic/${topicId}/pin`, {
|
||||||
|
|||||||
@ -21,6 +21,51 @@
|
|||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Filters bar */
|
||||||
|
.filters-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-label {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select {
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
background: var(--surface);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-reset {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-reset:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
.topics-list {
|
.topics-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -65,6 +110,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--spacing-sm);
|
gap: var(--spacing-sm);
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topic-title:hover {
|
.topic-title:hover {
|
||||||
@ -72,10 +118,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.topic-badge {
|
.topic-badge {
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-xs);
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-pinned {
|
.badge-pinned {
|
||||||
@ -88,6 +135,62 @@
|
|||||||
color: white;
|
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 {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
.topic-meta {
|
.topic-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--spacing-lg);
|
gap: var(--spacing-lg);
|
||||||
@ -170,6 +273,19 @@
|
|||||||
gap: var(--spacing-md);
|
gap: var(--spacing-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filters-bar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.topic-card {
|
.topic-card {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@ -193,6 +309,35 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="filters-bar">
|
||||||
|
<div class="filter-group">
|
||||||
|
<span class="filter-label">Kategoria:</span>
|
||||||
|
<select class="filter-select" id="categoryFilter" onchange="applyFilters()">
|
||||||
|
<option value="">Wszystkie</option>
|
||||||
|
{% for cat in categories %}
|
||||||
|
<option value="{{ cat }}" {% if category_filter == cat %}selected{% endif %}>
|
||||||
|
{{ category_labels.get(cat, cat) }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<span class="filter-label">Status:</span>
|
||||||
|
<select class="filter-select" id="statusFilter" onchange="applyFilters()">
|
||||||
|
<option value="">Wszystkie</option>
|
||||||
|
{% for st in statuses %}
|
||||||
|
<option value="{{ st }}" {% if status_filter == st %}selected{% endif %}>
|
||||||
|
{{ status_labels.get(st, st) }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{% if category_filter or status_filter %}
|
||||||
|
<a href="{{ url_for('forum_index') }}" class="filter-reset">Wyczysc filtry</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if topics %}
|
{% if topics %}
|
||||||
<div class="topics-list">
|
<div class="topics-list">
|
||||||
{% for topic in topics %}
|
{% for topic in topics %}
|
||||||
@ -205,6 +350,12 @@
|
|||||||
{% if topic.is_locked %}
|
{% if topic.is_locked %}
|
||||||
<span class="topic-badge badge-locked">Zamkniety</span>
|
<span class="topic-badge badge-locked">Zamkniety</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<span class="topic-badge badge-category badge-{{ topic.category or 'question' }}">
|
||||||
|
{{ category_labels.get(topic.category, 'Pytanie') }}
|
||||||
|
</span>
|
||||||
|
<span class="topic-badge badge-status badge-{{ topic.status or 'new' }}">
|
||||||
|
{{ status_labels.get(topic.status, 'Nowy') }}
|
||||||
|
</span>
|
||||||
{{ topic.title }}
|
{{ topic.title }}
|
||||||
</a>
|
</a>
|
||||||
<div class="topic-meta">
|
<div class="topic-meta">
|
||||||
@ -246,21 +397,21 @@
|
|||||||
{% if total_pages > 1 %}
|
{% if total_pages > 1 %}
|
||||||
<nav class="pagination">
|
<nav class="pagination">
|
||||||
{% if page > 1 %}
|
{% if page > 1 %}
|
||||||
<a href="{{ url_for('forum_index', page=page-1) }}">« Poprzednia</a>
|
<a href="{{ url_for('forum_index', page=page-1, category=category_filter, status=status_filter) }}">« Poprzednia</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% for p in range(1, total_pages + 1) %}
|
{% for p in range(1, total_pages + 1) %}
|
||||||
{% if p == page %}
|
{% if p == page %}
|
||||||
<span class="current">{{ p }}</span>
|
<span class="current">{{ p }}</span>
|
||||||
{% elif p <= 3 or p > total_pages - 3 or (p >= page - 1 and p <= page + 1) %}
|
{% elif p <= 3 or p > total_pages - 3 or (p >= page - 1 and p <= page + 1) %}
|
||||||
<a href="{{ url_for('forum_index', page=p) }}">{{ p }}</a>
|
<a href="{{ url_for('forum_index', page=p, category=category_filter, status=status_filter) }}">{{ p }}</a>
|
||||||
{% elif p == 4 or p == total_pages - 3 %}
|
{% elif p == 4 or p == total_pages - 3 %}
|
||||||
<span>...</span>
|
<span>...</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{% if page < total_pages %}
|
{% if page < total_pages %}
|
||||||
<a href="{{ url_for('forum_index', page=page+1) }}">Nastepna »</a>
|
<a href="{{ url_for('forum_index', page=page+1, category=category_filter, status=status_filter) }}">Nastepna »</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</nav>
|
</nav>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -278,3 +429,22 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
function applyFilters() {
|
||||||
|
const category = document.getElementById('categoryFilter').value;
|
||||||
|
const status = document.getElementById('statusFilter').value;
|
||||||
|
|
||||||
|
let url = '{{ url_for("forum_index") }}';
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (category) params.set('category', category);
|
||||||
|
if (status) params.set('status', status);
|
||||||
|
|
||||||
|
if (params.toString()) {
|
||||||
|
url += '?' + params.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.href = url;
|
||||||
|
}
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@ -60,7 +60,7 @@
|
|||||||
color: var(--error);
|
color: var(--error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input {
|
.form-input, .form-select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: var(--spacing-md);
|
padding: var(--spacing-md);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@ -68,9 +68,10 @@
|
|||||||
font-size: var(--font-size-base);
|
font-size: var(--font-size-base);
|
||||||
font-family: var(--font-family);
|
font-family: var(--font-family);
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input:focus {
|
.form-input:focus, .form-select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||||
@ -119,11 +120,102 @@
|
|||||||
margin-bottom: var(--spacing-xs);
|
margin-bottom: var(--spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Category select styles */
|
||||||
|
.category-group {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Upload dropzone */
|
||||||
|
.upload-dropzone {
|
||||||
|
border: 2px dashed var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
text-align: center;
|
||||||
|
background: var(--background);
|
||||||
|
transition: var(--transition);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-dropzone:hover, .upload-dropzone.drag-over {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: rgba(37, 99, 235, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-dropzone svg {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-dropzone p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-dropzone .upload-hint {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-preview {
|
||||||
|
display: none;
|
||||||
|
margin-top: var(--spacing-md);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
background: var(--background);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-preview.active {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-preview img {
|
||||||
|
max-width: 120px;
|
||||||
|
max-height: 80px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-preview .file-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-preview .file-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-preview .file-size {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-preview .remove-file {
|
||||||
|
color: var(--error);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-preview .remove-file:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.new-topic-form {
|
.new-topic-form {
|
||||||
padding: var(--spacing-lg);
|
padding: var(--spacing-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.category-group {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.form-actions {
|
.form-actions {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@ -157,25 +249,41 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="POST" action="{{ url_for('forum_new_topic') }}" novalidate>
|
<form method="POST" action="{{ url_for('forum_new_topic') }}" enctype="multipart/form-data" novalidate>
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="category-group">
|
||||||
<label for="title" class="form-label">
|
<div class="form-group">
|
||||||
Tytul tematu <span class="required">*</span>
|
<label for="category" class="form-label">
|
||||||
</label>
|
Kategoria <span class="required">*</span>
|
||||||
<input
|
</label>
|
||||||
type="text"
|
<select id="category" name="category" class="form-select" required>
|
||||||
id="title"
|
{% for cat in categories %}
|
||||||
name="title"
|
<option value="{{ cat }}" {% if cat == 'question' %}selected{% endif %}>
|
||||||
class="form-input"
|
{{ category_labels.get(cat, cat) }}
|
||||||
placeholder="Krotki, opisowy tytul..."
|
</option>
|
||||||
required
|
{% endfor %}
|
||||||
maxlength="255"
|
</select>
|
||||||
minlength="5"
|
<p class="form-hint">Wybierz typ tematu</p>
|
||||||
autofocus
|
</div>
|
||||||
>
|
|
||||||
<p class="form-hint">Minimum 5 znakow. Dobry tytul zacheca do dyskusji.</p>
|
<div class="form-group">
|
||||||
|
<label for="title" class="form-label">
|
||||||
|
Tytul tematu <span class="required">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="title"
|
||||||
|
name="title"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="Krotki, opisowy tytul..."
|
||||||
|
required
|
||||||
|
maxlength="255"
|
||||||
|
minlength="5"
|
||||||
|
autofocus
|
||||||
|
>
|
||||||
|
<p class="form-hint">Minimum 5 znakow</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@ -193,6 +301,33 @@
|
|||||||
<p class="form-hint">Minimum 10 znakow. Im wiecej szczegolow, tym lepsze odpowiedzi.</p>
|
<p class="form-hint">Minimum 10 znakow. Im wiecej szczegolow, tym lepsze odpowiedzi.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">
|
||||||
|
Zalacznik (opcjonalnie)
|
||||||
|
</label>
|
||||||
|
<div class="upload-dropzone" id="dropzone">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
<p>Przeciagnij obraz lub kliknij tutaj</p>
|
||||||
|
<span class="upload-hint">Mozesz tez wkleic ze schowka (Ctrl+V)</span>
|
||||||
|
<span class="upload-hint">JPG, PNG, GIF do 5MB</span>
|
||||||
|
<input type="file" id="attachment" name="attachment" accept="image/jpeg,image/png,image/gif" style="display: none;">
|
||||||
|
</div>
|
||||||
|
<div class="upload-preview" id="uploadPreview">
|
||||||
|
<img id="previewImage" src="" alt="Preview">
|
||||||
|
<div class="file-info">
|
||||||
|
<div class="file-name" id="fileName"></div>
|
||||||
|
<div class="file-size" id="fileSize"></div>
|
||||||
|
</div>
|
||||||
|
<div class="remove-file" id="removeFile" title="Usun">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn btn-primary btn-lg">
|
<button type="submit" class="btn btn-primary btn-lg">
|
||||||
Utworz temat
|
Utworz temat
|
||||||
@ -207,7 +342,6 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
<script>
|
|
||||||
// Client-side validation
|
// Client-side validation
|
||||||
document.querySelector('form').addEventListener('submit', function(e) {
|
document.querySelector('form').addEventListener('submit', function(e) {
|
||||||
const title = document.getElementById('title');
|
const title = document.getElementById('title');
|
||||||
@ -232,5 +366,103 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
|
||||||
|
// File upload handling
|
||||||
|
const dropzone = document.getElementById('dropzone');
|
||||||
|
const fileInput = document.getElementById('attachment');
|
||||||
|
const uploadPreview = document.getElementById('uploadPreview');
|
||||||
|
const previewImage = document.getElementById('previewImage');
|
||||||
|
const fileName = document.getElementById('fileName');
|
||||||
|
const fileSize = document.getElementById('fileSize');
|
||||||
|
const removeFile = document.getElementById('removeFile');
|
||||||
|
|
||||||
|
// Click to upload
|
||||||
|
dropzone.addEventListener('click', () => fileInput.click());
|
||||||
|
|
||||||
|
// Drag and drop
|
||||||
|
dropzone.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dropzone.classList.add('drag-over');
|
||||||
|
});
|
||||||
|
|
||||||
|
dropzone.addEventListener('dragleave', () => {
|
||||||
|
dropzone.classList.remove('drag-over');
|
||||||
|
});
|
||||||
|
|
||||||
|
dropzone.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dropzone.classList.remove('drag-over');
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
if (file && file.type.startsWith('image/')) {
|
||||||
|
handleFile(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// File input change
|
||||||
|
fileInput.addEventListener('change', (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
handleFile(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Paste from clipboard (Ctrl+V)
|
||||||
|
document.addEventListener('paste', (e) => {
|
||||||
|
const items = e.clipboardData?.items;
|
||||||
|
if (!items) return;
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
if (items[i].type.startsWith('image/')) {
|
||||||
|
e.preventDefault();
|
||||||
|
const file = items[i].getAsFile();
|
||||||
|
if (file) {
|
||||||
|
handleFile(file);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove file
|
||||||
|
removeFile.addEventListener('click', () => {
|
||||||
|
fileInput.value = '';
|
||||||
|
uploadPreview.classList.remove('active');
|
||||||
|
dropzone.style.display = 'block';
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleFile(file) {
|
||||||
|
// Validate file size (5MB)
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
alert('Plik jest za duzy (max 5MB)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file type
|
||||||
|
if (!['image/jpeg', 'image/png', 'image/gif'].includes(file.type)) {
|
||||||
|
alert('Dozwolone formaty: JPG, PNG, GIF');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new File object and assign to input
|
||||||
|
const dataTransfer = new DataTransfer();
|
||||||
|
dataTransfer.items.add(file);
|
||||||
|
fileInput.files = dataTransfer.files;
|
||||||
|
|
||||||
|
// Show preview
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
previewImage.src = e.target.result;
|
||||||
|
fileName.textContent = file.name;
|
||||||
|
fileSize.textContent = formatFileSize(file.size);
|
||||||
|
uploadPreview.classList.add('active');
|
||||||
|
dropzone.style.display = 'none';
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(bytes) {
|
||||||
|
if (bytes < 1024) return bytes + ' B';
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||||
|
}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -29,6 +29,7 @@
|
|||||||
|
|
||||||
.topic-header.pinned {
|
.topic-header.pinned {
|
||||||
border-left: 4px solid var(--primary);
|
border-left: 4px solid var(--primary);
|
||||||
|
background: linear-gradient(135deg, #eff6ff, var(--surface));
|
||||||
}
|
}
|
||||||
|
|
||||||
.topic-header.locked {
|
.topic-header.locked {
|
||||||
@ -70,6 +71,62 @@
|
|||||||
color: white;
|
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 {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
.topic-meta {
|
.topic-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--spacing-lg);
|
gap: var(--spacing-lg);
|
||||||
@ -90,6 +147,31 @@
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Attachments */
|
||||||
|
.topic-attachment {
|
||||||
|
margin-top: var(--spacing-lg);
|
||||||
|
padding-top: var(--spacing-lg);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-image {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 500px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-image:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-info {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
.replies-section {
|
.replies-section {
|
||||||
margin-top: var(--spacing-xl);
|
margin-top: var(--spacing-xl);
|
||||||
}
|
}
|
||||||
@ -154,6 +236,57 @@
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reply-attachments-container {
|
||||||
|
margin-top: var(--spacing-md);
|
||||||
|
padding-top: var(--spacing-md);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-attachments-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-attachment {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-attachment img {
|
||||||
|
width: 100%;
|
||||||
|
height: 120px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-attachment img:hover {
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-attachment .attachment-info {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 4px;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Single attachment - larger display */
|
||||||
|
.reply-attachments-grid.single-attachment {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-attachments-grid.single-attachment .reply-attachment img {
|
||||||
|
height: auto;
|
||||||
|
max-height: 300px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
.reply-form {
|
.reply-form {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
@ -185,6 +318,147 @@
|
|||||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Upload dropzone in reply form */
|
||||||
|
.upload-dropzone-mini {
|
||||||
|
border: 2px dashed var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
text-align: center;
|
||||||
|
background: var(--background);
|
||||||
|
transition: var(--transition);
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-dropzone-mini:hover,
|
||||||
|
.upload-dropzone-mini.drag-over {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: rgba(37, 99, 235, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-dropzone-mini p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-preview-mini {
|
||||||
|
display: none;
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
background: var(--background);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-preview-mini.active {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-preview-mini img {
|
||||||
|
max-width: 80px;
|
||||||
|
max-height: 60px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-preview-mini .file-info {
|
||||||
|
flex: 1;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-preview-mini .file-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-preview-mini .file-size {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-preview-mini .remove-file {
|
||||||
|
color: var(--error);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-preview-mini .remove-file:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Multi-file upload preview grid */
|
||||||
|
.upload-previews-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-preview-item {
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: var(--spacing-xs);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-preview-item img {
|
||||||
|
width: 100%;
|
||||||
|
height: 80px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-preview-item .preview-info {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-preview-item .remove-preview {
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
right: -6px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: var(--error);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-preview-item .remove-preview:hover {
|
||||||
|
background: #c53030;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-counter {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-counter.limit-reached {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.locked-notice {
|
.locked-notice {
|
||||||
background: #fef3c7;
|
background: #fef3c7;
|
||||||
border: 1px solid #f59e0b;
|
border: 1px solid #f59e0b;
|
||||||
@ -203,6 +477,31 @@
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Lightbox for images */
|
||||||
|
.lightbox {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.9);
|
||||||
|
z-index: 1000;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox img {
|
||||||
|
max-width: 90%;
|
||||||
|
max-height: 90%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.topic-title-row {
|
.topic-title-row {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -211,6 +510,11 @@
|
|||||||
.topic-meta {
|
.topic-meta {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -229,6 +533,12 @@
|
|||||||
{% if topic.is_locked %}
|
{% if topic.is_locked %}
|
||||||
<span class="topic-badge badge-locked">Zamkniety</span>
|
<span class="topic-badge badge-locked">Zamkniety</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<span class="topic-badge badge-category badge-{{ topic.category or 'question' }}">
|
||||||
|
{{ category_labels.get(topic.category, 'Pytanie') }}
|
||||||
|
</span>
|
||||||
|
<span class="topic-badge badge-status badge-{{ topic.status or 'new' }}">
|
||||||
|
{{ status_labels.get(topic.status, 'Nowy') }}
|
||||||
|
</span>
|
||||||
{{ topic.title }}
|
{{ topic.title }}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
@ -258,6 +568,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="topic-content">{{ topic.content }}</div>
|
<div class="topic-content">{{ topic.content }}</div>
|
||||||
|
|
||||||
|
{% if topic.attachments %}
|
||||||
|
{% for attachment in topic.attachments %}
|
||||||
|
<div class="topic-attachment">
|
||||||
|
<img src="{{ url_for('static', filename='uploads/forum/topics/' ~ topic.created_at.strftime('%Y/%m/') ~ attachment.stored_filename) }}"
|
||||||
|
alt="{{ attachment.original_filename }}"
|
||||||
|
class="attachment-image"
|
||||||
|
onclick="openLightbox(this.src)">
|
||||||
|
<div class="attachment-info">
|
||||||
|
{{ attachment.original_filename }} ({{ (attachment.file_size / 1024)|int }} KB)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<section class="replies-section">
|
<section class="replies-section">
|
||||||
@ -279,6 +603,23 @@
|
|||||||
<span>{{ reply.created_at.strftime('%d.%m.%Y %H:%M') }}</span>
|
<span>{{ reply.created_at.strftime('%d.%m.%Y %H:%M') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="reply-content">{{ reply.content }}</div>
|
<div class="reply-content">{{ reply.content }}</div>
|
||||||
|
|
||||||
|
{% if reply.attachments %}
|
||||||
|
<div class="reply-attachments-container">
|
||||||
|
<div class="reply-attachments-grid {% if reply.attachments|length == 1 %}single-attachment{% endif %}">
|
||||||
|
{% for attachment in reply.attachments %}
|
||||||
|
<div class="reply-attachment">
|
||||||
|
<img src="{{ url_for('static', filename='uploads/forum/replies/' ~ reply.created_at.strftime('%Y/%m/') ~ attachment.stored_filename) }}"
|
||||||
|
alt="{{ attachment.original_filename }}"
|
||||||
|
onclick="openLightbox(this.src)">
|
||||||
|
<div class="attachment-info">
|
||||||
|
{{ attachment.original_filename|truncate(20) }} ({{ (attachment.file_size / 1024)|int }} KB)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</article>
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@ -294,11 +635,221 @@
|
|||||||
Ten temat jest zamkniety. Nie mozna dodawac nowych odpowiedzi.
|
Ten temat jest zamkniety. Nie mozna dodawac nowych odpowiedzi.
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<form class="reply-form" method="POST" action="{{ url_for('forum_reply', topic_id=topic.id) }}">
|
<form class="reply-form" method="POST" action="{{ url_for('forum_reply', topic_id=topic.id) }}" enctype="multipart/form-data">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<h3>Dodaj odpowiedz</h3>
|
<h3>Dodaj odpowiedz</h3>
|
||||||
<textarea name="content" placeholder="Twoja odpowiedz..." required></textarea>
|
<textarea name="content" id="replyContent" placeholder="Twoja odpowiedz..." required></textarea>
|
||||||
<button type="submit" class="btn btn-primary">Wyslij odpowiedz</button>
|
|
||||||
|
<div class="upload-counter" id="uploadCounter"></div>
|
||||||
|
<div class="upload-previews-container" id="previewsContainer"></div>
|
||||||
|
<div class="upload-dropzone-mini" id="dropzone">
|
||||||
|
<p>Przeciagnij obrazy lub kliknij tutaj (max 10 plikow, mozesz tez wkleic Ctrl+V)</p>
|
||||||
|
<input type="file" id="attachmentInput" name="attachments[]" accept="image/jpeg,image/png,image/gif" multiple style="display: none;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Wyslij odpowiedz</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Lightbox for enlarged images -->
|
||||||
|
<div class="lightbox" id="lightbox" onclick="closeLightbox()">
|
||||||
|
<img id="lightboxImage" src="" alt="Enlarged image">
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
// Lightbox functions
|
||||||
|
function openLightbox(src) {
|
||||||
|
document.getElementById('lightboxImage').src = src;
|
||||||
|
document.getElementById('lightbox').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLightbox() {
|
||||||
|
document.getElementById('lightbox').classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close lightbox with Escape key
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeLightbox();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Multi-file upload handling (only if form exists)
|
||||||
|
const dropzone = document.getElementById('dropzone');
|
||||||
|
if (dropzone) {
|
||||||
|
const fileInput = document.getElementById('attachmentInput');
|
||||||
|
const previewsContainer = document.getElementById('previewsContainer');
|
||||||
|
const uploadCounter = document.getElementById('uploadCounter');
|
||||||
|
const replyContent = document.getElementById('replyContent');
|
||||||
|
|
||||||
|
const MAX_FILES = 10;
|
||||||
|
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
||||||
|
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif'];
|
||||||
|
|
||||||
|
// Store files in a Map for easy removal
|
||||||
|
let filesMap = new Map();
|
||||||
|
let fileIdCounter = 0;
|
||||||
|
|
||||||
|
// Click to upload
|
||||||
|
dropzone.addEventListener('click', () => fileInput.click());
|
||||||
|
|
||||||
|
// Drag and drop
|
||||||
|
dropzone.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dropzone.classList.add('drag-over');
|
||||||
|
});
|
||||||
|
|
||||||
|
dropzone.addEventListener('dragleave', () => {
|
||||||
|
dropzone.classList.remove('drag-over');
|
||||||
|
});
|
||||||
|
|
||||||
|
dropzone.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dropzone.classList.remove('drag-over');
|
||||||
|
const droppedFiles = Array.from(e.dataTransfer.files).filter(f => f.type.startsWith('image/'));
|
||||||
|
addFiles(droppedFiles);
|
||||||
|
});
|
||||||
|
|
||||||
|
// File input change
|
||||||
|
fileInput.addEventListener('change', (e) => {
|
||||||
|
const selectedFiles = Array.from(e.target.files);
|
||||||
|
addFiles(selectedFiles);
|
||||||
|
// Reset input to allow selecting same files again
|
||||||
|
fileInput.value = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Paste from clipboard (Ctrl+V)
|
||||||
|
document.addEventListener('paste', (e) => {
|
||||||
|
// Only handle paste if reply textarea is focused
|
||||||
|
if (document.activeElement !== replyContent && !replyContent.contains(document.activeElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = e.clipboardData?.items;
|
||||||
|
if (!items) return;
|
||||||
|
|
||||||
|
const pastedFiles = [];
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
if (items[i].type.startsWith('image/')) {
|
||||||
|
e.preventDefault();
|
||||||
|
const file = items[i].getAsFile();
|
||||||
|
if (file) {
|
||||||
|
pastedFiles.push(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pastedFiles.length > 0) {
|
||||||
|
addFiles(pastedFiles);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function addFiles(newFiles) {
|
||||||
|
const currentCount = filesMap.size;
|
||||||
|
const availableSlots = MAX_FILES - currentCount;
|
||||||
|
|
||||||
|
if (availableSlots <= 0) {
|
||||||
|
alert('Osiagnieto limit ' + MAX_FILES + ' plikow');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filesToAdd = newFiles.slice(0, availableSlots);
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
filesToAdd.forEach(file => {
|
||||||
|
// Validate size
|
||||||
|
if (file.size > MAX_SIZE) {
|
||||||
|
errors.push(file.name + ': za duzy (max 5MB)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate type
|
||||||
|
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||||
|
errors.push(file.name + ': niedozwolony format');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileId = 'file_' + (fileIdCounter++);
|
||||||
|
filesMap.set(fileId, file);
|
||||||
|
createPreview(fileId, file);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
alert('Bledy:\n' + errors.join('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCounter();
|
||||||
|
syncFilesToInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPreview(fileId, file) {
|
||||||
|
const preview = document.createElement('div');
|
||||||
|
preview.className = 'upload-preview-item';
|
||||||
|
preview.dataset.fileId = fileId;
|
||||||
|
|
||||||
|
const img = document.createElement('img');
|
||||||
|
const info = document.createElement('div');
|
||||||
|
info.className = 'preview-info';
|
||||||
|
info.textContent = file.name.substring(0, 15) + (file.name.length > 15 ? '...' : '') + ' (' + formatFileSize(file.size) + ')';
|
||||||
|
|
||||||
|
const removeBtn = document.createElement('button');
|
||||||
|
removeBtn.type = 'button';
|
||||||
|
removeBtn.className = 'remove-preview';
|
||||||
|
removeBtn.innerHTML = '×';
|
||||||
|
removeBtn.title = 'Usun';
|
||||||
|
removeBtn.onclick = () => removeFile(fileId);
|
||||||
|
|
||||||
|
preview.appendChild(img);
|
||||||
|
preview.appendChild(info);
|
||||||
|
preview.appendChild(removeBtn);
|
||||||
|
previewsContainer.appendChild(preview);
|
||||||
|
|
||||||
|
// Load image preview
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
img.src = e.target.result;
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFile(fileId) {
|
||||||
|
filesMap.delete(fileId);
|
||||||
|
const preview = previewsContainer.querySelector('[data-file-id="' + fileId + '"]');
|
||||||
|
if (preview) {
|
||||||
|
preview.remove();
|
||||||
|
}
|
||||||
|
updateCounter();
|
||||||
|
syncFilesToInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCounter() {
|
||||||
|
const count = filesMap.size;
|
||||||
|
if (count === 0) {
|
||||||
|
uploadCounter.textContent = '';
|
||||||
|
uploadCounter.classList.remove('limit-reached');
|
||||||
|
dropzone.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
uploadCounter.textContent = 'Wybrano: ' + count + '/' + MAX_FILES + ' plikow';
|
||||||
|
uploadCounter.classList.toggle('limit-reached', count >= MAX_FILES);
|
||||||
|
dropzone.style.display = count >= MAX_FILES ? 'none' : 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncFilesToInput() {
|
||||||
|
// Create DataTransfer and add all files from Map
|
||||||
|
const dataTransfer = new DataTransfer();
|
||||||
|
filesMap.forEach(file => {
|
||||||
|
dataTransfer.items.add(file);
|
||||||
|
});
|
||||||
|
fileInput.files = dataTransfer.files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(bytes) {
|
||||||
|
if (bytes < 1024) return bytes + ' B';
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||||
|
}
|
||||||
|
}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user