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 database import SessionLocal, User, Company, UserCompanyPermissions, PrivateMessage, UserNotification, UserBlock, Classified
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 email_service import send_email, build_message_notification_email
from message_upload_service import MessageUploadService
@ -169,7 +169,7 @@ def messages_send():
"""Wyślij wiadomość"""
recipient_id = request.form.get('recipient_id', type=int)
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_id = request.form.get('context_id', type=int)
@ -341,12 +341,49 @@ def messages_view(message_id):
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'])
@login_required
@member_required
def messages_reply(message_id):
"""Odpowiedz na wiadomość"""
content = request.form.get('content', '').strip()
content = sanitize_html(request.form.get('content', '').strip())
if not content:
flash('Treść jest wymagana.', 'error')

View File

@ -2,8 +2,35 @@
{% 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 %}
<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 {
max-width: 700px;
margin: 0 auto;
@ -350,8 +377,9 @@
</div>
<div class="form-group">
<label for="content">Tresc *</label>
<textarea id="content" name="content" rows="8" required placeholder="Napisz wiadomosc..."></textarea>
<label>Treść *</label>
<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 class="form-group">
@ -596,4 +624,87 @@
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 %}

View File

@ -2,6 +2,11 @@
{% 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 %}
<style>
.message-container {
@ -95,7 +100,23 @@
.message-body {
line-height: 1.7;
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 */
@ -246,7 +267,17 @@
font-size: var(--font-size-sm);
line-height: 1.6;
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 {
@ -419,7 +450,7 @@
{% endif %}
</div>
</div>
<div class="thread-msg-body">{{ msg.content }}</div>
<div class="thread-msg-body">{{ msg.content|safe }}</div>
{% if msg.attachments %}
<div class="attachments-section" style="margin: 0 var(--spacing-md) var(--spacing-sm); padding-top: var(--spacing-sm);">
{% for att in msg.attachments %}
@ -479,7 +510,7 @@
</div>
<div class="message-card-body">
<div class="message-body">{{ message.content }}</div>
<div class="message-body">{{ message.content|safe }}</div>
{% if message.attachments %}
<div class="attachments-section">
@ -538,7 +569,8 @@
<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() }}">
<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 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);">
@ -550,3 +582,81 @@
{% endif %}
</div>
{% 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__)
# 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_ATTRS = {'a': ['href', 'target', 'rel']}
_ALLOWED_TAGS = ['p', 'br', 'strong', 'em', 'b', 'i', 'a', 'ul', 'ol', 'li', 'h3', 'h4', 'blockquote', 'img']
_ALLOWED_ATTRS = {'a': ['href', 'target', 'rel'], 'img': ['src', 'alt']}
def sanitize_html(content):