nordabiz/templates/messages/view.html
Maciej Pienczyn b8f18c94e5
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): auto-linkify URLs in message content
URLs in messages are now automatically converted to clickable links
opening in a new tab. Works for both old plain-text and new Quill
HTML messages. Uses linkify Jinja2 filter that only processes text
nodes outside existing <a>/<img> tags.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 12:25:43 +01:00

664 lines
23 KiB
HTML
Executable File

{% extends "base.html" %}
{% 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 {
max-width: 800px;
margin: 0 auto;
}
.back-link {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
color: var(--text-secondary);
text-decoration: none;
margin-bottom: var(--spacing-lg);
font-size: var(--font-size-sm);
}
.back-link:hover {
color: var(--primary);
}
.message-card {
background: var(--surface);
border-radius: var(--radius-lg);
border: 1px solid var(--border-color, #e5e7eb);
margin-bottom: var(--spacing-lg);
overflow: hidden;
}
.message-card-header {
padding: var(--spacing-lg) var(--spacing-xl);
border-bottom: 1px solid var(--border-color, #f3f4f6);
}
.message-subject-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-md);
margin-bottom: var(--spacing-md);
}
.message-subject {
font-size: var(--font-size-xl);
font-weight: 600;
color: var(--text-primary);
}
.message-participants {
display: flex;
align-items: center;
gap: var(--spacing-md);
}
.participant-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: var(--font-size-base);
flex-shrink: 0;
}
.participant-avatar.is-me {
background: var(--secondary);
}
.participant-info {
flex: 1;
}
.participant-name {
font-weight: 500;
color: var(--text-primary);
}
.participant-detail {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.message-card-body {
padding: var(--spacing-lg) var(--spacing-xl);
}
.message-body {
line-height: 1.7;
color: var(--text-primary);
}
.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 */
.message-status-bar {
display: flex;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-sm) var(--spacing-xl);
background: var(--background, #f9fafb);
border-top: 1px solid var(--border-color, #f3f4f6);
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
.status-item {
display: inline-flex;
align-items: center;
gap: 4px;
}
.status-item svg {
width: 14px;
height: 14px;
}
.status-item.read {
color: #16a34a;
}
.status-item.waiting {
color: #d97706;
}
.status-item.sent {
color: var(--text-secondary);
}
.status-item.received {
color: var(--primary);
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 500;
}
.status-pill.read {
background: #dcfce7;
color: #15803d;
}
.status-pill.waiting {
background: #fef3c7;
color: #b45309;
}
.status-pill.sent {
background: #f3f4f6;
color: #6b7280;
}
.status-pill.received {
background: #dbeafe;
color: #1d4ed8;
}
/* Thread */
.thread-section {
margin-bottom: var(--spacing-lg);
}
.thread-section h3 {
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--spacing-md);
}
.thread-message {
background: var(--surface);
border: 1px solid var(--border-color, #e5e7eb);
border-radius: var(--radius-lg);
margin-bottom: var(--spacing-sm);
overflow: hidden;
}
.thread-message.is-current {
border-color: var(--primary);
box-shadow: 0 0 0 1px var(--primary);
}
.thread-msg-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-sm) var(--spacing-md);
gap: var(--spacing-sm);
}
.thread-msg-sender {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.thread-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 11px;
flex-shrink: 0;
}
.thread-avatar.is-me {
background: var(--secondary);
}
.thread-sender-name {
font-weight: 500;
font-size: var(--font-size-sm);
color: var(--text-primary);
}
.thread-msg-meta {
display: flex;
align-items: center;
gap: var(--spacing-sm);
font-size: var(--font-size-xs);
color: var(--text-secondary);
flex-shrink: 0;
}
.thread-msg-body {
padding: 0 var(--spacing-md) var(--spacing-sm);
font-size: var(--font-size-sm);
line-height: 1.6;
color: var(--text-primary);
}
.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 {
padding: var(--spacing-xs) var(--spacing-md) var(--spacing-sm);
}
/* Context badge */
.context-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: var(--radius);
font-size: var(--font-size-sm);
color: var(--primary);
margin-bottom: var(--spacing-md);
}
.context-badge svg {
width: 16px;
height: 16px;
}
.context-badge a {
color: var(--primary);
text-decoration: none;
font-weight: 500;
}
.context-badge a:hover {
text-decoration: underline;
}
.context-badge.inactive {
background: #f3f4f6;
border-color: #d1d5db;
color: var(--text-secondary);
}
.context-badge.inactive a {
color: var(--text-secondary);
}
/* Attachments */
.attachments-section {
margin-top: var(--spacing-md);
padding-top: var(--spacing-md);
border-top: 1px solid var(--border-color, #f3f4f6);
}
.attachments-label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-bottom: var(--spacing-sm);
font-weight: 500;
}
.attachment-item {
display: flex;
align-items: center;
gap: 10px;
padding: var(--spacing-xs) 0;
}
.attachment-item + .attachment-item {
border-top: 1px solid var(--border-color, #f3f4f6);
}
/* Reply form */
.reply-section {
background: var(--surface);
border-radius: var(--radius-lg);
border: 1px solid var(--border-color, #e5e7eb);
padding: var(--spacing-lg) var(--spacing-xl);
}
.reply-section h3 {
font-size: var(--font-size-base);
font-weight: 600;
margin-bottom: var(--spacing-md);
}
.form-group {
margin-bottom: var(--spacing-md);
}
.form-group textarea {
width: 100%;
padding: var(--spacing-md);
border: 1px solid var(--border-color, #e5e7eb);
border-radius: var(--radius);
font-size: var(--font-size-base);
resize: vertical;
font-family: inherit;
}
.form-group textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
@media (max-width: 640px) {
.message-card-header,
.message-card-body,
.message-status-bar {
padding-left: var(--spacing-md);
padding-right: var(--spacing-md);
}
.reply-section {
padding: var(--spacing-md);
}
.message-subject-row {
flex-direction: column;
}
}
</style>
{% endblock %}
{% block content %}
<div class="message-container">
<a href="{{ url_for('messages_inbox') }}" class="back-link">
<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>
Powrót do wiadomości
</a>
{% if thread %}
{# ===== WĄTEK — wiele wiadomości ===== #}
<div class="thread-section">
<h3>Konwersacja ({{ thread|length }} {{ 'wiadomość' if thread|length == 1 else ('wiadomości' if thread|length < 5 else 'wiadomości') }})</h3>
{% for msg in thread %}
<div class="thread-message {% if msg.id == message.id %}is-current{% endif %}">
<div class="thread-msg-header">
<div class="thread-msg-sender">
<div class="thread-avatar {% if msg.sender_id == current_user.id %}is-me{% endif %}">
{{ (msg.sender.name or msg.sender.email)[0].upper() }}
</div>
<span class="thread-sender-name">
{% if msg.sender_id == current_user.id %}Ty{% else %}{{ msg.sender.name or msg.sender.email.split('@')[0] }}{% endif %}
<span style="font-weight: 400; color: var(--text-secondary);">→ {% if msg.recipient_id == current_user.id %}Ty{% else %}{{ msg.recipient.name or msg.recipient.email.split('@')[0] }}{% endif %}</span>
</span>
</div>
<div class="thread-msg-meta">
<span>{{ msg.created_at.strftime('%d.%m.%Y %H:%M') }}</span>
{% if msg.sender_id == current_user.id %}
{% if msg.is_read %}
<span class="status-pill read" title="Przeczytana {{ msg.read_at.strftime('%d.%m.%Y %H:%M') if msg.read_at else '' }}">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 7l-8 8-4-4"/><path d="M22 7l-8 8-1.5-1.5" opacity="0.6"/></svg>
Przeczytana
</span>
{% else %}
<span class="status-pill waiting">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 7l-8 8-4-4"/></svg>
Nieprzeczytana
</span>
{% endif %}
{% else %}
<span class="status-pill received">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 13l4 4L19 7"/></svg>
Odebrana
</span>
{% endif %}
{% if msg.attachments %}
<span title="{{ msg.attachments|length }} załącznik{{ 'ów' if msg.attachments|length > 1 else '' }}">📎</span>
{% endif %}
</div>
</div>
<div class="thread-msg-body">{{ msg.content|linkify }}</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 %}
{% set is_image = att.mime_type and att.mime_type.startswith('image/') %}
<div class="attachment-item">
{% if is_image %}
<a href="/static/uploads/messages/{{ att.created_at.strftime('%Y/%m') }}/{{ att.stored_filename }}" target="_blank">
<img src="/static/uploads/messages/{{ att.created_at.strftime('%Y/%m') }}/{{ att.stored_filename }}" alt="{{ att.filename }}" style="max-width: 200px; max-height: 120px; border-radius: var(--radius); border: 1px solid var(--border-color, #e5e7eb);">
</a>
{% else %}
<span style="font-size: 18px;">📄</span>
<div>
<a href="/static/uploads/messages/{{ att.created_at.strftime('%Y/%m') }}/{{ att.stored_filename }}" download="{{ att.filename }}" style="color: var(--primary); font-weight: 500; font-size: var(--font-size-sm);">{{ att.filename }}</a>
<span style="font-size: var(--font-size-xs); color: var(--text-secondary); margin-left: 6px;">{{ (att.file_size / 1024)|round(0)|int }} KB</span>
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
{# ===== POJEDYNCZA WIADOMOŚĆ ===== #}
<div class="message-card">
{% if context %}
<div style="padding: var(--spacing-md) var(--spacing-xl) 0;">
<div class="context-badge {% if not context.is_active %}inactive{% endif %}">
<svg 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>
Dotyczy ogłoszenia: <a href="{{ context.url }}">{{ context.title }}</a>
{% if not context.is_active %}<span style="color: var(--text-secondary);">(nieaktywne)</span>{% endif %}
</div>
</div>
{% endif %}
<div class="message-card-header">
<div class="message-subject-row">
<div class="message-subject">{{ message.subject or '(brak tematu)' }}</div>
</div>
<div class="message-participants">
<div class="participant-avatar {% if message.sender_id == current_user.id %}is-me{% endif %}">
{{ (message.sender.name or message.sender.email)[0].upper() }}
</div>
<div class="participant-info">
<div class="participant-name">
{% if message.sender_id == current_user.id %}Ty{% else %}{{ message.sender.name or message.sender.email.split('@')[0] }}{% endif %}
</div>
<div class="participant-detail">
Do: {% if message.recipient_id == current_user.id %}mnie{% else %}{{ message.recipient.name or message.recipient.email.split('@')[0] }}{% endif %}
</div>
</div>
</div>
</div>
<div class="message-card-body">
<div class="message-body">{{ message.content|linkify }}</div>
{% if message.attachments %}
<div class="attachments-section">
<div class="attachments-label">Załączniki ({{ message.attachments|length }})</div>
{% for att in message.attachments %}
{% set is_image = att.mime_type and att.mime_type.startswith('image/') %}
<div class="attachment-item">
{% if is_image %}
<a href="/static/uploads/messages/{{ att.created_at.strftime('%Y/%m') }}/{{ att.stored_filename }}" target="_blank">
<img src="/static/uploads/messages/{{ att.created_at.strftime('%Y/%m') }}/{{ att.stored_filename }}" alt="{{ att.filename }}" style="max-width: 300px; max-height: 200px; border-radius: var(--radius); border: 1px solid var(--border-color, #e5e7eb);">
</a>
{% else %}
<span style="font-size: 20px;">📄</span>
<div>
<a href="/static/uploads/messages/{{ att.created_at.strftime('%Y/%m') }}/{{ att.stored_filename }}" download="{{ att.filename }}" style="color: var(--primary); font-weight: 500;">{{ att.filename }}</a>
<span style="font-size: var(--font-size-xs); color: var(--text-secondary); margin-left: 8px;">{{ (att.file_size / 1024)|round(0)|int }} KB</span>
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
<div class="message-status-bar">
<span class="status-item sent">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
{{ message.created_at.strftime('%d.%m.%Y o %H:%M') }}
</span>
{% if message.sender_id == current_user.id %}
{% if message.is_read %}
<span class="status-pill read" title="Przeczytana {{ message.read_at.strftime('%d.%m.%Y o %H:%M') if message.read_at else '' }}">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 7l-8 8-4-4"/><path d="M22 7l-8 8-1.5-1.5" opacity="0.6"/></svg>
Przeczytana{{ ' ' + message.read_at.strftime('%d.%m o %H:%M') if message.read_at else '' }}
</span>
{% else %}
<span class="status-pill waiting">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 7l-8 8-4-4"/></svg>
Oczekuje na przeczytanie
</span>
{% endif %}
{% else %}
<span class="status-pill received">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 13l4 4L19 7"/></svg>
Odebrana
</span>
{% endif %}
</div>
</div>
{% endif %}
{# ===== FORMULARZ ODPOWIEDZI ===== #}
{% if message.sender_id != current_user.id or message.recipient_id != current_user.id %}
<div class="reply-section">
<h3>Odpowiedz</h3>
<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">
<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);">
<span style="font-size: var(--font-size-xs); color: var(--text-secondary);">Maks. 3 pliki, 15MB łącznie</span>
</div>
<button type="submit" class="btn btn-primary">Wyślij odpowiedź</button>
</form>
</div>
{% 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.stopImmediatePropagation();
e.preventDefault();
var file = items[i].getAsFile();
if (file) uploadImage(file);
return;
}
}
}, true);
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 %}