feat(messages): add Quill rich text editor with inline image paste
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions

- Replace plain textarea with Quill editor in compose and reply forms
- Support Ctrl+V paste of screenshots directly into message body
- Image toolbar button for file picker upload
- New endpoint POST /api/messages/upload-image for inline images
- Content sanitized via sanitize_html (bleach) with img tag support
- Messages rendered as HTML (|safe) instead of plain text
- Links clickable, images displayed inline in message body

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-03-19 12:18:42 +01:00
parent 88f8d98af9
commit 9c296644f7
4 changed files with 270 additions and 12 deletions

View File

@ -14,7 +14,7 @@ from . import bp
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from database import SessionLocal, User, Company, UserCompanyPermissions, PrivateMessage, UserNotification, UserBlock, Classified from database import SessionLocal, User, Company, UserCompanyPermissions, PrivateMessage, UserNotification, UserBlock, Classified
from extensions import limiter from extensions import limiter
from utils.helpers import sanitize_input from utils.helpers import sanitize_input, sanitize_html
from utils.decorators import member_required from utils.decorators import member_required
from email_service import send_email, build_message_notification_email from email_service import send_email, build_message_notification_email
from message_upload_service import MessageUploadService from message_upload_service import MessageUploadService
@ -169,7 +169,7 @@ def messages_send():
"""Wyślij wiadomość""" """Wyślij wiadomość"""
recipient_id = request.form.get('recipient_id', type=int) recipient_id = request.form.get('recipient_id', type=int)
subject = sanitize_input(request.form.get('subject', ''), 255) subject = sanitize_input(request.form.get('subject', ''), 255)
content = request.form.get('content', '').strip() content = sanitize_html(request.form.get('content', '').strip())
context_type = request.form.get('context_type') context_type = request.form.get('context_type')
context_id = request.form.get('context_id', type=int) context_id = request.form.get('context_id', type=int)
@ -341,12 +341,49 @@ def messages_view(message_id):
db.close() db.close()
@bp.route('/api/messages/upload-image', methods=['POST'])
@login_required
@member_required
def messages_upload_image():
"""Upload inline image for Quill editor in messages."""
import os
import uuid
from datetime import datetime as dt
from werkzeug.utils import secure_filename
file = request.files.get('image')
if not file or not file.filename:
return jsonify({'error': 'Brak pliku'}), 400
filename = secure_filename(file.filename)
ext = os.path.splitext(filename)[1].lower()
if ext not in ('.jpg', '.jpeg', '.png', '.gif', '.webp'):
return jsonify({'error': 'Dozwolone formaty: JPG, PNG, GIF, WebP'}), 400
# Read and check size (max 5MB)
data = file.read()
if len(data) > 5 * 1024 * 1024:
return jsonify({'error': 'Maksymalny rozmiar obrazka: 5 MB'}), 400
# Save to uploads
now = dt.now()
upload_dir = os.path.join('static', 'uploads', 'messages', str(now.year), f'{now.month:02d}')
os.makedirs(upload_dir, exist_ok=True)
stored_name = f'{uuid.uuid4().hex}{ext}'
filepath = os.path.join(upload_dir, stored_name)
with open(filepath, 'wb') as f:
f.write(data)
url = f'/{filepath}'
return jsonify({'url': url})
@bp.route('/wiadomosci/<int:message_id>/odpowiedz', methods=['POST']) @bp.route('/wiadomosci/<int:message_id>/odpowiedz', methods=['POST'])
@login_required @login_required
@member_required @member_required
def messages_reply(message_id): def messages_reply(message_id):
"""Odpowiedz na wiadomość""" """Odpowiedz na wiadomość"""
content = request.form.get('content', '').strip() content = sanitize_html(request.form.get('content', '').strip())
if not content: if not content:
flash('Treść jest wymagana.', 'error') flash('Treść jest wymagana.', 'error')

View File

@ -2,8 +2,35 @@
{% block title %}Nowa wiadomosc - Norda Biznes Partner{% endblock %} {% block title %}Nowa wiadomosc - Norda Biznes Partner{% endblock %}
{% block head_extra %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/quill.snow.css') }}">
<script src="{{ url_for('static', filename='js/vendor/quill.js') }}"></script>
{% endblock %}
{% block extra_css %} {% block extra_css %}
<style> <style>
.quill-container {
border: 1px solid var(--border);
border-radius: var(--radius);
}
.quill-container .ql-toolbar {
border-top-left-radius: var(--radius);
border-top-right-radius: var(--radius);
}
.quill-container .ql-container {
border-bottom-left-radius: var(--radius);
border-bottom-right-radius: var(--radius);
font-size: var(--font-size-base);
}
.quill-container .ql-editor {
min-height: 200px;
}
.quill-container .ql-editor img {
max-width: 100%;
height: auto;
border-radius: var(--radius);
margin: var(--spacing-sm) 0;
}
.compose-container { .compose-container {
max-width: 700px; max-width: 700px;
margin: 0 auto; margin: 0 auto;
@ -350,8 +377,9 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="content">Tresc *</label> <label>Treść *</label>
<textarea id="content" name="content" rows="8" required placeholder="Napisz wiadomosc..."></textarea> <div id="quill-content" class="quill-container" style="min-height: 200px; background: var(--surface);"></div>
<textarea id="content" name="content" style="display:none;" required></textarea>
</div> </div>
<div class="form-group"> <div class="form-group">
@ -596,4 +624,87 @@
updateFileList(); updateFileList();
}; };
})(); })();
/* Quill editor for message content */
(function() {
var csrfToken = '{{ csrf_token() }}';
var quill = new Quill('#quill-content', {
theme: 'snow',
placeholder: 'Napisz wiadomość...',
modules: {
toolbar: {
container: [
['bold', 'italic'],
[{'list': 'ordered'}, {'list': 'bullet'}],
['link', 'image'],
['clean']
],
handlers: {
image: function() {
var input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'image/*');
input.click();
input.onchange = function() {
if (input.files && input.files[0]) {
uploadImage(input.files[0]);
}
};
}
}
}
}
});
function uploadImage(file) {
var fd = new FormData();
fd.append('image', file);
fetch('/api/messages/upload-image', {
method: 'POST',
headers: {'X-CSRFToken': csrfToken},
body: fd
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.url) {
var range = quill.getSelection(true);
quill.insertEmbed(range.index, 'image', data.url);
quill.setSelection(range.index + 1);
} else {
alert(data.error || 'Błąd uploadu');
}
})
.catch(function() { alert('Błąd połączenia'); });
}
/* Handle paste with images (screenshots) */
quill.root.addEventListener('paste', function(e) {
var items = (e.clipboardData || {}).items || [];
for (var i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
e.preventDefault();
var file = items[i].getAsFile();
if (file) uploadImage(file);
return;
}
}
});
/* Sync Quill content to hidden textarea on every change */
var textarea = document.getElementById('content');
quill.on('text-change', function() {
var html = quill.root.innerHTML;
textarea.value = (html === '<p><br></p>') ? '' : html;
});
/* Validate before submit */
document.querySelector('form').addEventListener('submit', function(e) {
var html = quill.root.innerHTML;
textarea.value = (html === '<p><br></p>') ? '' : html;
if (!textarea.value.trim()) {
e.preventDefault();
alert('Treść wiadomości jest wymagana.');
}
});
})();
{% endblock %} {% endblock %}

View File

@ -2,6 +2,11 @@
{% block title %}{{ message.subject or 'Wiadomość' }} - Norda Biznes Partner{% endblock %} {% block title %}{{ message.subject or 'Wiadomość' }} - Norda Biznes Partner{% endblock %}
{% block head_extra %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/quill.snow.css') }}">
<script src="{{ url_for('static', filename='js/vendor/quill.js') }}"></script>
{% endblock %}
{% block extra_css %} {% block extra_css %}
<style> <style>
.message-container { .message-container {
@ -95,7 +100,23 @@
.message-body { .message-body {
line-height: 1.7; line-height: 1.7;
color: var(--text-primary); color: var(--text-primary);
white-space: pre-wrap; }
.message-body img {
max-width: 100%;
height: auto;
border-radius: var(--radius);
margin: var(--spacing-sm) 0;
display: block;
}
.message-body a {
color: var(--primary);
text-decoration: underline;
}
.message-body p {
margin-bottom: var(--spacing-sm);
} }
/* Status bar */ /* Status bar */
@ -246,7 +267,17 @@
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
line-height: 1.6; line-height: 1.6;
color: var(--text-primary); color: var(--text-primary);
white-space: pre-wrap; }
.thread-msg-body img {
max-width: 100%;
height: auto;
border-radius: var(--radius);
margin: var(--spacing-xs) 0;
}
.thread-msg-body a {
color: var(--primary);
} }
.thread-msg-status { .thread-msg-status {
@ -419,7 +450,7 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div class="thread-msg-body">{{ msg.content }}</div> <div class="thread-msg-body">{{ msg.content|safe }}</div>
{% if msg.attachments %} {% if msg.attachments %}
<div class="attachments-section" style="margin: 0 var(--spacing-md) var(--spacing-sm); padding-top: var(--spacing-sm);"> <div class="attachments-section" style="margin: 0 var(--spacing-md) var(--spacing-sm); padding-top: var(--spacing-sm);">
{% for att in msg.attachments %} {% for att in msg.attachments %}
@ -479,7 +510,7 @@
</div> </div>
<div class="message-card-body"> <div class="message-card-body">
<div class="message-body">{{ message.content }}</div> <div class="message-body">{{ message.content|safe }}</div>
{% if message.attachments %} {% if message.attachments %}
<div class="attachments-section"> <div class="attachments-section">
@ -538,7 +569,8 @@
<form method="POST" action="{{ url_for('messages_reply', message_id=message.id) }}" enctype="multipart/form-data"> <form method="POST" action="{{ url_for('messages_reply', message_id=message.id) }}" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group"> <div class="form-group">
<textarea name="content" rows="4" required placeholder="Napisz odpowiedź…"></textarea> <div id="quill-reply" style="min-height: 120px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);"></div>
<textarea name="content" id="reply-content" style="display:none;" required></textarea>
</div> </div>
<div class="form-group" style="margin-top: 8px;"> <div class="form-group" style="margin-top: 8px;">
<input type="file" name="attachments" multiple accept=".jpg,.jpeg,.png,.gif,.pdf,.docx,.xlsx" style="font-size: var(--font-size-sm);"> <input type="file" name="attachments" multiple accept=".jpg,.jpeg,.png,.gif,.pdf,.docx,.xlsx" style="font-size: var(--font-size-sm);">
@ -550,3 +582,81 @@
{% endif %} {% endif %}
</div> </div>
{% endblock %} {% endblock %}
{% block extra_js %}
(function() {
var replyDiv = document.getElementById('quill-reply');
if (!replyDiv) return;
var csrfToken = '{{ csrf_token() }}';
var quill = new Quill('#quill-reply', {
theme: 'snow',
placeholder: 'Napisz odpowiedź...',
modules: {
toolbar: {
container: [
['bold', 'italic'],
['link', 'image'],
['clean']
],
handlers: {
image: function() {
var input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'image/*');
input.click();
input.onchange = function() {
if (input.files && input.files[0]) uploadImage(input.files[0]);
};
}
}
}
}
});
function uploadImage(file) {
var fd = new FormData();
fd.append('image', file);
fetch('/api/messages/upload-image', {
method: 'POST',
headers: {'X-CSRFToken': csrfToken},
body: fd
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.url) {
var range = quill.getSelection(true);
quill.insertEmbed(range.index, 'image', data.url);
quill.setSelection(range.index + 1);
}
});
}
quill.root.addEventListener('paste', function(e) {
var items = (e.clipboardData || {}).items || [];
for (var i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
e.preventDefault();
var file = items[i].getAsFile();
if (file) uploadImage(file);
return;
}
}
});
var textarea = document.getElementById('reply-content');
quill.on('text-change', function() {
var html = quill.root.innerHTML;
textarea.value = (html === '<p><br></p>') ? '' : html;
});
replyDiv.closest('form').addEventListener('submit', function(e) {
var html = quill.root.innerHTML;
textarea.value = (html === '<p><br></p>') ? '' : html;
if (!textarea.value.trim()) {
e.preventDefault();
alert('Treść odpowiedzi jest wymagana.');
}
});
})();
{% endblock %}

View File

@ -13,8 +13,8 @@ import bleach
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Allowed HTML tags and attributes for rich-text content (announcements, events, proceedings) # Allowed HTML tags and attributes for rich-text content (announcements, events, proceedings)
_ALLOWED_TAGS = ['p', 'br', 'strong', 'em', 'b', 'i', 'a', 'ul', 'ol', 'li', 'h3', 'h4', 'blockquote'] _ALLOWED_TAGS = ['p', 'br', 'strong', 'em', 'b', 'i', 'a', 'ul', 'ol', 'li', 'h3', 'h4', 'blockquote', 'img']
_ALLOWED_ATTRS = {'a': ['href', 'target', 'rel']} _ALLOWED_ATTRS = {'a': ['href', 'target', 'rel'], 'img': ['src', 'alt']}
def sanitize_html(content): def sanitize_html(content):