nordabiz/templates/messages/compose.html
Maciej Pienczyn 9c296644f7
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
feat(messages): add Quill rich text editor with inline image paste
- 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>
2026-03-19 12:18:42 +01:00

711 lines
26 KiB
HTML
Executable File

{% extends "base.html" %}
{% 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;
}
.compose-header {
margin-bottom: var(--spacing-xl);
}
.compose-header h1 {
font-size: var(--font-size-3xl);
color: var(--text-primary);
}
.compose-card {
background: var(--surface);
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
box-shadow: var(--shadow);
}
.form-group {
margin-bottom: var(--spacing-lg);
}
.form-group label {
display: block;
font-weight: 500;
margin-bottom: var(--spacing-xs);
color: var(--text-primary);
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: var(--font-size-base);
transition: var(--transition);
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.form-actions {
display: flex;
gap: var(--spacing-md);
margin-top: var(--spacing-xl);
}
.back-link {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
color: var(--text-secondary);
text-decoration: none;
margin-bottom: var(--spacing-lg);
}
.back-link:hover {
color: var(--primary);
}
.recipient-info {
display: flex;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-md);
background: var(--background);
border-radius: var(--radius);
margin-bottom: var(--spacing-lg);
}
.recipient-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: var(--font-size-lg);
}
.recipient-name {
font-weight: 500;
}
.recipient-email {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.context-info {
display: flex;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-md);
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: var(--radius);
margin-bottom: var(--spacing-lg);
}
.context-icon {
width: 40px;
height: 40px;
border-radius: var(--radius);
background: var(--primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
}
.context-label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.context-title {
font-weight: 500;
color: var(--text-primary);
}
.context-title a {
color: var(--primary);
text-decoration: none;
}
.context-title a:hover {
text-decoration: underline;
}
.recipient-autocomplete {
position: relative;
}
.recipient-autocomplete input[type="text"] {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: var(--font-size-base);
transition: var(--transition);
}
.recipient-autocomplete input[type="text"]:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.autocomplete-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--surface);
border: 1px solid var(--border);
border-top: none;
border-radius: 0 0 var(--radius) var(--radius);
max-height: 240px;
overflow-y: auto;
z-index: 100;
box-shadow: var(--shadow-lg);
display: none;
}
.autocomplete-results.visible {
display: block;
}
.autocomplete-item {
padding: var(--spacing-sm) var(--spacing-md);
cursor: pointer;
display: flex;
align-items: center;
gap: var(--spacing-sm);
transition: background 0.15s;
}
.autocomplete-item:hover,
.autocomplete-item.active {
background: var(--background);
}
.autocomplete-item-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: var(--font-size-sm);
flex-shrink: 0;
}
.autocomplete-item-name {
font-weight: 500;
}
.autocomplete-item-email {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.autocomplete-no-results {
padding: var(--spacing-sm) var(--spacing-md);
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.selected-recipient {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
background: var(--background);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.selected-recipient-remove {
margin-left: auto;
background: none;
border: none;
cursor: pointer;
color: var(--text-secondary);
padding: 4px;
border-radius: var(--radius-sm);
line-height: 1;
}
.selected-recipient-remove:hover {
color: var(--danger);
background: rgba(239, 68, 68, 0.1);
}
</style>
{% endblock %}
{% block content %}
<div class="compose-container">
<div style="display: flex; align-items: center; gap: var(--spacing-lg); flex-wrap: wrap; margin-bottom: var(--spacing-lg);">
<a href="{{ url_for('messages_inbox') }}" class="back-link" style="margin-bottom: 0;">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
Powrot do wiadomosci
</a>
{% if from_company %}
<a href="{{ url_for('company_detail', company_id=from_company.id) }}" class="back-link" style="margin-bottom: 0;">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
Powrot do {{ from_company.name }}
</a>
{% endif %}
</div>
<div class="compose-header">
<h1>Nowa wiadomosc</h1>
</div>
<div class="compose-card">
{% if recipient %}
<div class="recipient-info">
<div class="recipient-avatar">{{ (recipient.name or recipient.email)[0].upper() }}</div>
<div>
<div class="recipient-name">{{ recipient.name or recipient.email.split('@')[0] }}</div>
{% if recipient.privacy_show_email != False %}
<div class="recipient-email">{{ recipient.email }}</div>
{% endif %}
</div>
</div>
{% endif %}
{% if context %}
<div class="context-info">
<div class="context-icon">
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
</div>
<div>
<div class="context-label">Dotyczy ogloszenia B2B:</div>
<div class="context-title"><a href="{{ context.url }}" target="_blank">{{ context.title }}</a></div>
</div>
</div>
{% endif %}
<form method="POST" action="{{ url_for('messages_send') }}" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if context_type %}
<input type="hidden" name="context_type" value="{{ context_type }}">
<input type="hidden" name="context_id" value="{{ context_id }}">
{% endif %}
{% if recipient %}
<input type="hidden" name="recipient_id" value="{{ recipient.id }}">
{% else %}
<div class="form-group">
<label for="recipient_search">Do *</label>
<input type="hidden" id="recipient_id" name="recipient_id" required>
<div id="recipient-selected" class="selected-recipient" style="display: none;">
<div class="autocomplete-item-avatar" id="selected-avatar"></div>
<div>
<div class="autocomplete-item-name" id="selected-name"></div>
<div class="autocomplete-item-email" id="selected-email"></div>
</div>
<button type="button" class="selected-recipient-remove" onclick="clearRecipient()" title="Zmien odbiorcę"></button>
</div>
<div id="recipient-preview" style="display: none; margin-top: 8px; padding: 12px 16px; background: var(--bg-secondary); border-radius: var(--radius); border: 1px solid var(--border-color);">
<div style="display: flex; align-items: center; gap: 12px;">
<div id="preview-avatar" style="width: 40px; height: 40px; border-radius: 50%; background: var(--primary); color: white; display: flex; align-items: center; justify-content: center; font-weight: 600; font-size: 16px;"></div>
<div>
<div id="preview-name" style="font-weight: 600; color: var(--text-primary);"></div>
<div id="preview-company" style="font-size: var(--font-size-sm); color: var(--text-secondary);"></div>
</div>
</div>
</div>
<div id="recipient-autocomplete" class="recipient-autocomplete">
<input type="text" id="recipient_search" placeholder="Wpisz imie, nazwisko lub email..." autocomplete="off">
<div id="autocomplete-results" class="autocomplete-results"></div>
</div>
</div>
{% endif %}
<div class="form-group">
<label for="subject">Temat</label>
<input type="text" id="subject" name="subject" maxlength="255" placeholder="Temat wiadomosci (opcjonalnie)" value="{{ context_subject or '' }}">
</div>
<div class="form-group">
<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">
<label>Załączniki (maks. 3 pliki, 15MB łącznie)</label>
<div class="file-upload-zone" id="file-drop-zone" style="border: 2px dashed var(--border-color); border-radius: var(--radius); padding: 20px; text-align: center; cursor: pointer; transition: border-color 0.2s;">
<input type="file" name="attachments" id="file-input" multiple accept=".jpg,.jpeg,.png,.gif,.pdf,.docx,.xlsx" style="display: none;">
<p style="margin: 0; color: var(--text-secondary); font-size: var(--font-size-sm);">
Przeciągnij pliki tutaj lub <a href="#" onclick="document.getElementById('file-input').click(); return false;" style="color: var(--primary);">wybierz z dysku</a>
</p>
<p style="margin: 4px 0 0; color: var(--text-secondary); font-size: var(--font-size-xs);">
JPG, PNG, GIF (5MB) · PDF, DOCX, XLSX (10MB)
</p>
</div>
<div id="file-list" style="margin-top: 8px;"></div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Wyslij</button>
<a href="{{ url_for('messages_inbox') }}" class="btn btn-secondary">Anuluj</a>
{% if recipient and recipient.email and recipient.privacy_show_email != False %}
<a href="mailto:{{ recipient.email }}" class="btn btn-secondary" style="margin-left: auto; display: inline-flex; align-items: center; gap: 6px;">
<svg width="16" height="16" fill="currentColor" viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg>
Wyslij e-mail
</a>
{% endif %}
</div>
<div class="form-hint" style="text-align: center; margin-top: 12px; padding: 8px 16px; font-size: var(--font-size-sm); color: var(--text-secondary);">
📧 Odbiorca zostanie powiadomiony o nowej wiadomości emailem
</div>
</form>
</div>
</div>
{% endblock %}
{% block extra_js %}
{% if not recipient %}
(function() {
var users = [
{% for user in users %}
{id: {{ user.id }}, name: {{ (user.name or user.email.split('@')[0]) | tojson }}, email: {{ user.email | tojson }}, showEmail: {{ 'true' if user.privacy_show_email != False else 'false' }}, companyName: {{ (user._company_name or '') | tojson }}, companySlug: {{ (user._company_slug or '') | tojson }}, position: {{ (user._position or '') | tojson }}}{{ ',' if not loop.last }}
{% endfor %}
];
var searchInput = document.getElementById('recipient_search');
var resultsDiv = document.getElementById('autocomplete-results');
var hiddenInput = document.getElementById('recipient_id');
var selectedDiv = document.getElementById('recipient-selected');
var autocompleteDiv = document.getElementById('recipient-autocomplete');
var activeIndex = -1;
function normalize(str) {
return str.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
}
function filterUsers(query) {
if (!query) return [];
var q = normalize(query);
return users.filter(function(u) {
return normalize(u.name).indexOf(q) !== -1 || normalize(u.email).indexOf(q) !== -1;
});
}
function renderResults(matches) {
activeIndex = -1;
if (matches.length === 0) {
resultsDiv.innerHTML = '<div class="autocomplete-no-results">Nie znaleziono</div>';
resultsDiv.classList.add('visible');
return;
}
resultsDiv.innerHTML = matches.map(function(u, i) {
var initial = (u.name || u.email)[0].toUpperCase();
var emailPart = u.showEmail ? '<div class="autocomplete-item-email">' + u.email + '</div>' : '';
return '<div class="autocomplete-item" data-index="' + i + '" data-id="' + u.id + '" data-name="' + u.name.replace(/"/g, '&quot;') + '" data-email="' + u.email.replace(/"/g, '&quot;') + '" data-show-email="' + u.showEmail + '">' +
'<div class="autocomplete-item-avatar">' + initial + '</div>' +
'<div><div class="autocomplete-item-name">' + u.name + '</div>' + emailPart + '</div></div>';
}).join('');
resultsDiv.classList.add('visible');
}
function selectRecipient(id, name, email, showEmail) {
hiddenInput.value = id;
document.getElementById('selected-avatar').textContent = (name || email)[0].toUpperCase();
document.getElementById('selected-name').textContent = name;
document.getElementById('selected-email').textContent = showEmail ? email : '';
selectedDiv.style.display = 'flex';
autocompleteDiv.style.display = 'none';
resultsDiv.classList.remove('visible');
searchInput.value = '';
// Show recipient preview card
var user = users.find(function(u) { return u.id === id; });
var previewDiv = document.getElementById('recipient-preview');
if (user && (user.companyName)) {
document.getElementById('preview-avatar').textContent = (name || email)[0].toUpperCase();
document.getElementById('preview-name').textContent = name;
var companyHtml = '';
if (user.companyName) {
companyHtml = user.companySlug
? '<a href="/firma/' + user.companySlug + '" target="_blank" style="color: var(--primary); text-decoration: none;">' + user.companyName + '</a>'
: user.companyName;
}
if (user.position) {
companyHtml = user.position + (companyHtml ? ' · ' + companyHtml : '');
}
document.getElementById('preview-company').innerHTML = companyHtml;
previewDiv.style.display = 'block';
} else {
previewDiv.style.display = 'none';
}
}
window.clearRecipient = function() {
hiddenInput.value = '';
selectedDiv.style.display = 'none';
autocompleteDiv.style.display = 'block';
searchInput.value = '';
searchInput.focus();
document.getElementById('recipient-preview').style.display = 'none';
};
searchInput.addEventListener('input', function() {
var q = this.value.trim();
if (q.length === 0) {
resultsDiv.classList.remove('visible');
return;
}
renderResults(filterUsers(q));
});
searchInput.addEventListener('focus', function() {
if (this.value.trim().length > 0) {
renderResults(filterUsers(this.value.trim()));
}
});
resultsDiv.addEventListener('click', function(e) {
var item = e.target.closest('.autocomplete-item');
if (item) {
selectRecipient(item.dataset.id, item.dataset.name, item.dataset.email, item.dataset.showEmail === 'true');
}
});
searchInput.addEventListener('keydown', function(e) {
var items = resultsDiv.querySelectorAll('.autocomplete-item');
if (!items.length) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
activeIndex = Math.min(activeIndex + 1, items.length - 1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
activeIndex = Math.max(activeIndex - 1, 0);
} else if (e.key === 'Enter' && activeIndex >= 0) {
e.preventDefault();
var item = items[activeIndex];
selectRecipient(item.dataset.id, item.dataset.name, item.dataset.email, item.dataset.showEmail === 'true');
return;
} else if (e.key === 'Escape') {
resultsDiv.classList.remove('visible');
return;
} else {
return;
}
items.forEach(function(el) { el.classList.remove('active'); });
items[activeIndex].classList.add('active');
items[activeIndex].scrollIntoView({block: 'nearest'});
});
document.addEventListener('click', function(e) {
if (!autocompleteDiv.contains(e.target)) {
resultsDiv.classList.remove('visible');
}
});
searchInput.closest('form').addEventListener('submit', function(e) {
if (!hiddenInput.value) {
e.preventDefault();
searchInput.focus();
searchInput.style.borderColor = 'var(--danger)';
setTimeout(function() { searchInput.style.borderColor = ''; }, 2000);
}
});
})();
{% endif %}
// File attachment handling
(function() {
var dropZone = document.getElementById('file-drop-zone');
var fileInput = document.getElementById('file-input');
var fileList = document.getElementById('file-list');
if (!dropZone) return;
dropZone.addEventListener('click', function(e) {
if (e.target.tagName !== 'A') fileInput.click();
});
dropZone.addEventListener('dragover', function(e) {
e.preventDefault();
dropZone.style.borderColor = 'var(--primary)';
});
dropZone.addEventListener('dragleave', function() {
dropZone.style.borderColor = 'var(--border-color)';
});
dropZone.addEventListener('drop', function(e) {
e.preventDefault();
dropZone.style.borderColor = 'var(--border-color)';
var dt = new DataTransfer();
Array.from(e.dataTransfer.files).forEach(function(f) { dt.items.add(f); });
Array.from(fileInput.files).forEach(function(f) { dt.items.add(f); });
fileInput.files = dt.files;
updateFileList();
});
fileInput.addEventListener('change', updateFileList);
function updateFileList() {
var files = Array.from(fileInput.files);
if (files.length === 0) {
fileList.innerHTML = '';
return;
}
fileList.innerHTML = files.map(function(f, i) {
var sizeMB = (f.size / 1024 / 1024).toFixed(1);
return '<div style="display: flex; align-items: center; gap: 8px; padding: 6px 0; font-size: var(--font-size-sm);">' +
'<span style="color: var(--text-secondary);">📎</span> ' +
'<span>' + f.name + '</span> ' +
'<span style="color: var(--text-secondary);">(' + sizeMB + ' MB)</span> ' +
'<a href="#" onclick="removeFile(' + i + '); return false;" style="color: var(--danger); margin-left: auto;"></a>' +
'</div>';
}).join('');
}
window.removeFile = function(index) {
var dt = new DataTransfer();
Array.from(fileInput.files).forEach(function(f, i) {
if (i !== index) dt.items.add(f);
});
fileInput.files = dt.files;
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 %}