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
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:
parent
88f8d98af9
commit
9c296644f7
@ -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')
|
||||||
|
|||||||
@ -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 %}
|
||||||
|
|||||||
@ -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 %}
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user