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,
|
||||
ForumTopic,
|
||||
ForumReply,
|
||||
ForumAttachment,
|
||||
NordaEvent,
|
||||
EventAttendee,
|
||||
PrivateMessage,
|
||||
@ -112,6 +113,7 @@ import gemini_service
|
||||
from nordabiz_chat import NordaBizChatEngine
|
||||
from search_service import search_companies
|
||||
import krs_api_service
|
||||
from file_upload_service import FileUploadService
|
||||
|
||||
# News service for fetching company news
|
||||
try:
|
||||
@ -812,14 +814,25 @@ def events():
|
||||
@app.route('/forum')
|
||||
@login_required
|
||||
def forum_index():
|
||||
"""Forum - list of topics"""
|
||||
"""Forum - list of topics with category/status filters"""
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = 20
|
||||
category_filter = request.args.get('category', '')
|
||||
status_filter = request.args.get('status', '')
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Get topics ordered by pinned first, then by last activity
|
||||
query = db.query(ForumTopic).order_by(
|
||||
# Build query with optional filters
|
||||
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.updated_at.desc()
|
||||
)
|
||||
@ -833,7 +846,13 @@ def forum_index():
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
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:
|
||||
db.close()
|
||||
@ -842,36 +861,70 @@ def forum_index():
|
||||
@app.route('/forum/nowy', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def forum_new_topic():
|
||||
"""Create new forum topic"""
|
||||
"""Create new forum topic with category and attachments"""
|
||||
if request.method == 'POST':
|
||||
title = sanitize_input(request.form.get('title', ''), 255)
|
||||
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:
|
||||
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:
|
||||
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()
|
||||
try:
|
||||
topic = ForumTopic(
|
||||
title=title,
|
||||
content=content,
|
||||
author_id=current_user.id
|
||||
author_id=current_user.id,
|
||||
category=category
|
||||
)
|
||||
db.add(topic)
|
||||
db.commit()
|
||||
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')
|
||||
return redirect(url_for('forum_topic', topic_id=topic.id))
|
||||
finally:
|
||||
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>')
|
||||
@ -890,7 +943,10 @@ def forum_topic(topic_id):
|
||||
topic.views_count += 1
|
||||
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:
|
||||
db.close()
|
||||
|
||||
@ -898,7 +954,7 @@ def forum_topic(topic_id):
|
||||
@app.route('/forum/<int:topic_id>/odpowiedz', methods=['POST'])
|
||||
@login_required
|
||||
def forum_reply(topic_id):
|
||||
"""Add reply to forum topic"""
|
||||
"""Add reply to forum topic with optional attachment"""
|
||||
content = request.form.get('content', '').strip()
|
||||
|
||||
if not content or len(content) < 3:
|
||||
@ -923,6 +979,44 @@ def forum_reply(topic_id):
|
||||
content=content
|
||||
)
|
||||
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
|
||||
topic.updated_at = datetime.now()
|
||||
@ -964,6 +1058,15 @@ def admin_forum():
|
||||
pinned_count = sum(1 for t in topics if t.is_pinned)
|
||||
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(
|
||||
'admin/forum.html',
|
||||
topics=topics,
|
||||
@ -971,7 +1074,13 @@ def admin_forum():
|
||||
total_topics=total_topics,
|
||||
total_replies=total_replies,
|
||||
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:
|
||||
db.close()
|
||||
@ -1081,6 +1190,45 @@ def admin_forum_delete_reply(reply_id):
|
||||
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
|
||||
# ============================================================
|
||||
|
||||
100
database.py
100
database.py
@ -153,7 +153,7 @@ class User(Base, UserMixin):
|
||||
|
||||
# Relationships
|
||||
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')
|
||||
|
||||
def __repr__(self):
|
||||
@ -791,7 +791,14 @@ class ForumTopic(Base):
|
||||
content = Column(Text, 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_locked = Column(Boolean, default=False)
|
||||
views_count = Column(Integer, default=0)
|
||||
@ -800,9 +807,30 @@ class ForumTopic(Base):
|
||||
created_at = Column(DateTime, default=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
|
||||
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')
|
||||
attachments = relationship('ForumAttachment', back_populates='topic', cascade='all, delete-orphan',
|
||||
primaryjoin="and_(ForumAttachment.topic_id==ForumTopic.id, ForumAttachment.attachment_type=='topic')")
|
||||
|
||||
@property
|
||||
def reply_count(self):
|
||||
@ -814,6 +842,14 @@ class ForumTopic(Base):
|
||||
return max(r.created_at for r in self.replies)
|
||||
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):
|
||||
"""Forum replies to topics"""
|
||||
@ -831,6 +867,64 @@ class ForumReply(Base):
|
||||
# Relationships
|
||||
topic = relationship('ForumTopic', back_populates='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):
|
||||
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: var(--spacing-lg);
|
||||
margin-bottom: var(--spacing-2xl);
|
||||
}
|
||||
@ -40,6 +40,16 @@
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.stat-card.category-feature_request .stat-value { color: #1e40af; }
|
||||
.stat-card.category-bug .stat-value { color: #991b1b; }
|
||||
.stat-card.category-question .stat-value { color: #166534; }
|
||||
.stat-card.category-announcement .stat-value { color: #92400e; }
|
||||
|
||||
.stat-card.status-new .stat-value { color: #374151; }
|
||||
.stat-card.status-in_progress .stat-value { color: #1e40af; }
|
||||
.stat-card.status-resolved .stat-value { color: #166534; }
|
||||
.stat-card.status-rejected .stat-value { color: #991b1b; }
|
||||
|
||||
.section {
|
||||
background: var(--surface);
|
||||
padding: var(--spacing-xl);
|
||||
@ -105,7 +115,6 @@
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.badge-pinned {
|
||||
@ -118,6 +127,65 @@
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Category badges */
|
||||
.badge-category {
|
||||
border: 1px solid;
|
||||
}
|
||||
|
||||
.badge-feature_request {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
border-color: #93c5fd;
|
||||
}
|
||||
|
||||
.badge-bug {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
border-color: #fca5a5;
|
||||
}
|
||||
|
||||
.badge-question {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
border-color: #86efac;
|
||||
}
|
||||
|
||||
.badge-announcement {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
border-color: #fcd34d;
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.badge-status {
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.badge-status:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.badge-new {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.badge-in_progress {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.badge-resolved {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.badge-rejected {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
@ -193,6 +261,75 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Status change modal */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-overlay.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-xl);
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-select, .form-input {
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--font-size-base);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.form-select:focus, .form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.topics-table {
|
||||
font-size: var(--font-size-sm);
|
||||
@ -200,8 +337,8 @@
|
||||
|
||||
.topics-table th:nth-child(3),
|
||||
.topics-table td:nth-child(3),
|
||||
.topics-table th:nth-child(4),
|
||||
.topics-table td:nth-child(4) {
|
||||
.topics-table th:nth-child(5),
|
||||
.topics-table td:nth-child(5) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@ -234,6 +371,46 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Stats -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card status-new">
|
||||
<div class="stat-value">{{ status_counts.get('new', 0) }}</div>
|
||||
<div class="stat-label">Nowych</div>
|
||||
</div>
|
||||
<div class="stat-card status-in_progress">
|
||||
<div class="stat-value">{{ status_counts.get('in_progress', 0) }}</div>
|
||||
<div class="stat-label">W realizacji</div>
|
||||
</div>
|
||||
<div class="stat-card status-resolved">
|
||||
<div class="stat-value">{{ status_counts.get('resolved', 0) }}</div>
|
||||
<div class="stat-label">Rozwiazanych</div>
|
||||
</div>
|
||||
<div class="stat-card status-rejected">
|
||||
<div class="stat-value">{{ status_counts.get('rejected', 0) }}</div>
|
||||
<div class="stat-label">Odrzuconych</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category Stats -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card category-feature_request">
|
||||
<div class="stat-value">{{ category_counts.get('feature_request', 0) }}</div>
|
||||
<div class="stat-label">Propozycji</div>
|
||||
</div>
|
||||
<div class="stat-card category-bug">
|
||||
<div class="stat-value">{{ category_counts.get('bug', 0) }}</div>
|
||||
<div class="stat-label">Bledow</div>
|
||||
</div>
|
||||
<div class="stat-card category-question">
|
||||
<div class="stat-value">{{ category_counts.get('question', 0) }}</div>
|
||||
<div class="stat-label">Pytan</div>
|
||||
</div>
|
||||
<div class="stat-card category-announcement">
|
||||
<div class="stat-value">{{ category_counts.get('announcement', 0) }}</div>
|
||||
<div class="stat-label">Ogloszen</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Topics Section -->
|
||||
<div class="section">
|
||||
<h2>Tematy</h2>
|
||||
@ -242,10 +419,10 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tytul</th>
|
||||
<th>Kategoria</th>
|
||||
<th>Autor</th>
|
||||
<th>Odpowiedzi</th>
|
||||
<th>Data</th>
|
||||
<th>Status</th>
|
||||
<th>Data</th>
|
||||
<th>Akcje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -256,11 +433,6 @@
|
||||
<div class="topic-title">
|
||||
<a href="{{ url_for('forum_topic', topic_id=topic.id) }}">{{ topic.title }}</a>
|
||||
</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 %}
|
||||
<span class="badge badge-pinned">Przypiety</span>
|
||||
{% endif %}
|
||||
@ -268,6 +440,20 @@
|
||||
<span class="badge badge-locked">Zamkniety</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-category badge-{{ topic.category or 'question' }}">
|
||||
{{ category_labels.get(topic.category, 'Pytanie') }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="topic-meta">{{ topic.author.name or topic.author.email.split('@')[0] }}</td>
|
||||
<td>
|
||||
<span class="badge badge-status badge-{{ topic.status or 'new' }}"
|
||||
onclick="openStatusModal({{ topic.id }}, '{{ topic.title|e }}', '{{ topic.status or 'new' }}')"
|
||||
title="Kliknij, aby zmienic status">
|
||||
{{ status_labels.get(topic.status, 'Nowy') }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="topic-meta">{{ topic.created_at.strftime('%d.%m.%Y') }}</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
<button class="btn-icon {% if topic.is_pinned %}active{% endif %}"
|
||||
@ -338,16 +524,103 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Status Change Modal -->
|
||||
<div class="modal-overlay" id="statusModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">Zmien status tematu</div>
|
||||
<div class="modal-body">
|
||||
<p id="modalTopicTitle" style="margin-bottom: var(--spacing-md); color: var(--text-secondary);"></p>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Nowy status</label>
|
||||
<select class="form-select" id="newStatus">
|
||||
<option value="new">Nowy</option>
|
||||
<option value="in_progress">W realizacji</option>
|
||||
<option value="resolved">Rozwiazany</option>
|
||||
<option value="rejected">Odrzucony</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Notatka (opcjonalnie)</label>
|
||||
<input type="text" class="form-input" id="statusNote" placeholder="Krotki komentarz do zmiany statusu...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline" onclick="closeStatusModal()">Anuluj</button>
|
||||
<button class="btn btn-primary" onclick="saveStatus()">Zapisz</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
const csrfToken = '{{ csrf_token() }}';
|
||||
let currentTopicId = null;
|
||||
|
||||
function showMessage(message, type) {
|
||||
// Simple alert for now - could be improved with toast notifications
|
||||
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) {
|
||||
try {
|
||||
const response = await fetch(`/admin/forum/topic/${topicId}/pin`, {
|
||||
|
||||
@ -21,6 +21,51 @@
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -65,6 +110,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.topic-title:hover {
|
||||
@ -72,10 +118,11 @@
|
||||
}
|
||||
|
||||
.topic-badge {
|
||||
font-size: var(--font-size-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-pinned {
|
||||
@ -88,6 +135,62 @@
|
||||
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 {
|
||||
display: flex;
|
||||
gap: var(--spacing-lg);
|
||||
@ -170,6 +273,19 @@
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.filters-bar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.topic-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@ -193,6 +309,35 @@
|
||||
</a>
|
||||
</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 %}
|
||||
<div class="topics-list">
|
||||
{% for topic in topics %}
|
||||
@ -205,6 +350,12 @@
|
||||
{% if topic.is_locked %}
|
||||
<span class="topic-badge badge-locked">Zamkniety</span>
|
||||
{% 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 }}
|
||||
</a>
|
||||
<div class="topic-meta">
|
||||
@ -246,21 +397,21 @@
|
||||
{% if total_pages > 1 %}
|
||||
<nav class="pagination">
|
||||
{% 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 %}
|
||||
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
{% if p == page %}
|
||||
<span class="current">{{ p }}</span>
|
||||
{% 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 %}
|
||||
<span>...</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% 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 %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
@ -278,3 +429,22 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
{% 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);
|
||||
}
|
||||
|
||||
.form-input {
|
||||
.form-input, .form-select {
|
||||
width: 100%;
|
||||
padding: var(--spacing-md);
|
||||
border: 1px solid var(--border);
|
||||
@ -68,9 +68,10 @@
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
transition: var(--transition);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
.form-input:focus, .form-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||
@ -119,11 +120,102 @@
|
||||
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) {
|
||||
.new-topic-form {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.category-group {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
@ -157,9 +249,24 @@
|
||||
</ul>
|
||||
</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() }}">
|
||||
|
||||
<div class="category-group">
|
||||
<div class="form-group">
|
||||
<label for="category" class="form-label">
|
||||
Kategoria <span class="required">*</span>
|
||||
</label>
|
||||
<select id="category" name="category" class="form-select" required>
|
||||
{% for cat in categories %}
|
||||
<option value="{{ cat }}" {% if cat == 'question' %}selected{% endif %}>
|
||||
{{ category_labels.get(cat, cat) }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="form-hint">Wybierz typ tematu</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="title" class="form-label">
|
||||
Tytul tematu <span class="required">*</span>
|
||||
@ -175,7 +282,8 @@
|
||||
minlength="5"
|
||||
autofocus
|
||||
>
|
||||
<p class="form-hint">Minimum 5 znakow. Dobry tytul zacheca do dyskusji.</p>
|
||||
<p class="form-hint">Minimum 5 znakow</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@ -193,6 +301,33 @@
|
||||
<p class="form-hint">Minimum 10 znakow. Im wiecej szczegolow, tym lepsze odpowiedzi.</p>
|
||||
</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">
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
Utworz temat
|
||||
@ -207,7 +342,6 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Client-side validation
|
||||
document.querySelector('form').addEventListener('submit', function(e) {
|
||||
const title = document.getElementById('title');
|
||||
@ -232,5 +366,103 @@
|
||||
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 %}
|
||||
|
||||
@ -29,6 +29,7 @@
|
||||
|
||||
.topic-header.pinned {
|
||||
border-left: 4px solid var(--primary);
|
||||
background: linear-gradient(135deg, #eff6ff, var(--surface));
|
||||
}
|
||||
|
||||
.topic-header.locked {
|
||||
@ -70,6 +71,62 @@
|
||||
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 {
|
||||
display: flex;
|
||||
gap: var(--spacing-lg);
|
||||
@ -90,6 +147,31 @@
|
||||
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 {
|
||||
margin-top: var(--spacing-xl);
|
||||
}
|
||||
@ -154,6 +236,57 @@
|
||||
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 {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
@ -185,6 +318,147 @@
|
||||
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 {
|
||||
background: #fef3c7;
|
||||
border: 1px solid #f59e0b;
|
||||
@ -203,6 +477,31 @@
|
||||
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) {
|
||||
.topic-title-row {
|
||||
flex-direction: column;
|
||||
@ -211,6 +510,11 @@
|
||||
.topic-meta {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@ -229,6 +533,12 @@
|
||||
{% if topic.is_locked %}
|
||||
<span class="topic-badge badge-locked">Zamkniety</span>
|
||||
{% 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 }}
|
||||
</h1>
|
||||
</div>
|
||||
@ -258,6 +568,20 @@
|
||||
</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>
|
||||
|
||||
<section class="replies-section">
|
||||
@ -279,6 +603,23 @@
|
||||
<span>{{ reply.created_at.strftime('%d.%m.%Y %H:%M') }}</span>
|
||||
</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>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@ -294,11 +635,221 @@
|
||||
Ten temat jest zamkniety. Nie mozna dodawac nowych odpowiedzi.
|
||||
</div>
|
||||
{% 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() }}">
|
||||
<h3>Dodaj odpowiedz</h3>
|
||||
<textarea name="content" placeholder="Twoja odpowiedz..." required></textarea>
|
||||
<textarea name="content" id="replyContent" placeholder="Twoja odpowiedz..." required></textarea>
|
||||
|
||||
<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>
|
||||
{% 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 %}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user