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:
Maciej Pienczyn 2026-01-10 21:26:20 +01:00
parent cdc53d9ff3
commit 61e70ad67c
8 changed files with 1901 additions and 55 deletions

172
app.py
View File

@ -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
# ============================================================ # ============================================================

View File

@ -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):

View 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
View 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}"

View File

@ -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`, {

View File

@ -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) }}">&laquo; Poprzednia</a> <a href="{{ url_for('forum_index', page=page-1, category=category_filter, status=status_filter) }}">&laquo; 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 &raquo;</a> <a href="{{ url_for('forum_index', page=page+1, category=category_filter, status=status_filter) }}">Nastepna &raquo;</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 %}

View File

@ -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 %}

View File

@ -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 = '&times;';
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 %}