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 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')
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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):
|
||||
|
||||
Loading…
Reference in New Issue
Block a user