nordabiz/templates/messages/group_view.html
Maciej Pienczyn 110d971dca
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: migrate prod docs to OVH VPS + UTC→Warsaw timezone in all templates
Production moved from on-prem VM 249 (10.22.68.249) to OVH VPS
(57.128.200.27, inpi-vps-waw01). Updated ALL documentation, slash
commands, memory files, architecture docs, and deploy procedures.

Added |local_time Jinja filter (UTC→Europe/Warsaw) and converted
155 .strftime() calls across 71 templates so timestamps display
in Polish timezone regardless of server timezone.

Also includes: created_by_id tracking, abort import fix, ICS
calendar fix for missing end times, Pros Poland data cleanup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:41:53 +02:00

709 lines
24 KiB
HTML

{% extends "base.html" %}
{% block title %}{{ group.display_name }} - 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 %}
.group-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);
}
/* Group header */
.group-header {
background: var(--surface);
border-radius: var(--radius-lg);
border: 1px solid var(--border-color, #e5e7eb);
padding: var(--spacing-lg) var(--spacing-xl);
margin-bottom: var(--spacing-lg);
}
.group-header-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-md);
}
.group-title {
font-size: var(--font-size-xl);
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.group-member-count {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 10px;
background: #eff6ff;
border-radius: 12px;
font-size: var(--font-size-xs);
font-weight: 500;
color: var(--primary);
}
.group-manage-link {
font-size: var(--font-size-sm);
color: var(--primary);
text-decoration: none;
font-weight: 500;
}
.group-manage-link:hover {
text-decoration: underline;
}
/* Members collapsible */
.members-toggle {
display: flex;
align-items: center;
gap: var(--spacing-xs);
margin-top: var(--spacing-md);
padding: 0;
background: none;
border: none;
cursor: pointer;
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--text-secondary);
}
.members-toggle:hover {
color: var(--text-primary);
}
.members-toggle svg {
transition: transform 0.2s;
}
.members-toggle.open svg {
transform: rotate(90deg);
}
.members-list {
display: none;
margin-top: var(--spacing-sm);
padding-top: var(--spacing-sm);
border-top: 1px solid var(--border-color, #f3f4f6);
}
.members-list.visible {
display: block;
}
.member-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-xs) 0;
}
.member-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.member-initial {
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;
}
.member-name {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--text-primary);
}
.role-badge {
display: inline-block;
padding: 1px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 500;
}
.role-badge.owner {
background: #fef3c7;
color: #92400e;
}
.role-badge.moderator {
background: #dbeafe;
color: #1d4ed8;
}
/* Messages */
.messages-section {
margin-bottom: var(--spacing-lg);
}
.group-message {
display: flex;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-md);
}
.msg-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.msg-initial {
width: 36px;
height: 36px;
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;
}
.msg-initial.is-me {
background: var(--secondary);
}
.msg-bubble {
flex: 1;
min-width: 0;
}
.msg-header {
display: flex;
align-items: baseline;
gap: var(--spacing-sm);
margin-bottom: 2px;
}
.msg-sender {
font-weight: 500;
font-size: var(--font-size-sm);
color: var(--text-primary);
}
.msg-time {
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
.msg-delete-btn {
background: none;
border: none;
cursor: pointer;
font-size: 12px;
opacity: 0;
transition: opacity 0.15s;
padding: 2px;
line-height: 1;
}
.msg-header:hover .msg-delete-btn {
opacity: 0.5;
}
.msg-delete-btn:hover {
opacity: 1 !important;
}
.msg-content {
background: var(--surface);
border: 1px solid var(--border-color, #e5e7eb);
border-radius: var(--radius);
padding: var(--spacing-sm) var(--spacing-md);
font-size: var(--font-size-sm);
line-height: 1.6;
color: var(--text-primary);
}
.msg-content img {
max-width: 100%;
height: auto;
border-radius: var(--radius);
margin: var(--spacing-xs) 0;
}
.msg-content a {
color: var(--primary);
}
.msg-content p {
margin-bottom: var(--spacing-xs);
}
.msg-content p:last-child {
margin-bottom: 0;
}
.msg-attachments {
margin-top: var(--spacing-xs);
}
.msg-attachment-item {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 0;
font-size: var(--font-size-sm);
}
/* Read receipts */
.msg-read-receipts {
display: flex;
gap: 2px;
justify-content: flex-end;
margin-top: 4px;
padding-right: 4px;
}
.read-receipt {
width: 20px;
height: 20px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
}
.read-receipt img {
width: 100%;
height: 100%;
object-fit: cover;
}
.read-receipt span {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
background: var(--primary);
color: white;
font-size: 10px;
font-weight: 600;
border-radius: 50%;
}
/* 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);
}
.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: 120px;
}
.quill-container .ql-editor img {
max-width: 100%;
height: auto;
border-radius: var(--radius);
margin: var(--spacing-sm) 0;
}
.empty-state {
text-align: center;
padding: var(--spacing-2xl) var(--spacing-lg);
color: var(--text-secondary);
}
@media (max-width: 640px) {
.group-header {
padding: var(--spacing-md);
}
.group-header-top {
flex-direction: column;
align-items: flex-start;
}
.reply-section {
padding: var(--spacing-md);
}
}
{% endblock %}
{% block content %}
<div class="group-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>
Powrot do wiadomosci
</a>
{# ===== GROUP HEADER ===== #}
<div class="group-header">
<div class="group-header-top">
<div class="group-title">
{{ group.display_name }}
<span class="group-member-count">{{ members|length }}</span>
</div>
{% if membership.is_owner or membership.is_moderator %}
<a href="{{ url_for('messages.group_manage', group_id=group.id) }}" class="group-manage-link">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" style="vertical-align: -2px;">
<path d="M12.22 2h-.44a2 2 0 00-2 2v.18a2 2 0 01-1 1.73l-.43.25a2 2 0 01-2 0l-.15-.08a2 2 0 00-2.73.73l-.22.38a2 2 0 00.73 2.73l.15.1a2 2 0 011 1.72v.51a2 2 0 01-1 1.74l-.15.09a2 2 0 00-.73 2.73l.22.38a2 2 0 002.73.73l.15-.08a2 2 0 012 0l.43.25a2 2 0 011 1.73V20a2 2 0 002 2h.44a2 2 0 002-2v-.18a2 2 0 011-1.73l.43-.25a2 2 0 012 0l.15.08a2 2 0 002.73-.73l.22-.39a2 2 0 00-.73-2.73l-.15-.08a2 2 0 01-1-1.74v-.5a2 2 0 011-1.74l.15-.09a2 2 0 00.73-2.73l-.22-.38a2 2 0 00-2.73-.73l-.15.08a2 2 0 01-2 0l-.43-.25a2 2 0 01-1-1.73V4a2 2 0 00-2-2z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
Zarzadzaj
</a>
{% endif %}
</div>
<button class="members-toggle" id="members-toggle" type="button">
<svg width="12" height="12" fill="currentColor" viewBox="0 0 24 24"><path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6z"/></svg>
Uczestnicy ({{ members|length }})
</button>
<div class="members-list" id="members-list">
{% for m in members %}
<div class="member-item">
<a href="{{ url_for('public.user_profile', user_id=m.user_id) }}" style="display: flex; align-items: center; gap: inherit; text-decoration: none; color: inherit;" {% if m.user_id != current_user.id %}onmouseover="this.querySelector('.member-name').style.textDecoration='underline'" onmouseout="this.querySelector('.member-name').style.textDecoration='none'"{% endif %}>
{% if m.user.avatar_path %}
<img src="{{ url_for('static', filename=m.user.avatar_path) }}" class="member-avatar" alt="">
{% else %}
<div class="member-initial">{{ (m.user.name or m.user.email)[0].upper() }}</div>
{% endif %}
<span class="member-name">
{% if m.user_id == current_user.id %}Ty{% else %}{{ m.user.name or m.user.email.split('@')[0] }}{% endif %}
</span>
</a>
{% if m.is_owner %}
<span class="role-badge owner">Wlasciciel</span>
{% elif m.is_moderator %}
<span class="role-badge moderator">Moderator</span>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{# ===== MESSAGES ===== #}
<div class="messages-section" id="messages-section">
{% if messages %}
{% for msg in messages %}
<div class="group-message">
<a href="{{ url_for('public.user_profile', user_id=msg.sender_id) }}" style="text-decoration: none; flex-shrink: 0;">
{% if msg.sender.avatar_path %}
<img src="{{ url_for('static', filename=msg.sender.avatar_path) }}" class="msg-avatar" alt="">
{% else %}
<div class="msg-initial {% if msg.sender_id == current_user.id %}is-me{% endif %}">
{{ (msg.sender.name or msg.sender.email)[0].upper() }}
</div>
{% endif %}
</a>
<div class="msg-bubble">
<div class="msg-header">
<span class="msg-sender">{% if msg.sender_id == current_user.id %}Ty{% else %}<a href="{{ url_for('public.user_profile', user_id=msg.sender_id) }}" style="color: inherit; text-decoration: none;" onmouseover="this.style.textDecoration='underline'" onmouseout="this.style.textDecoration='none'">{{ msg.sender.name or msg.sender.email.split('@')[0] }}</a>{% endif %}</span>
<span class="msg-time">{{ msg.created_at|local_time('%d.%m.%Y %H:%M') }}</span>
{% if msg.sender_id == current_user.id or membership.is_owner %}
<form method="POST" action="{{ url_for('messages.group_delete_message', group_id=group.id, message_id=msg.id) }}" style="display:inline; margin-left: 4px;" onsubmit="return nordaConfirm(this, 'Wiadomość zostanie trwale usunięta.', {title: 'Usunąć wiadomość?', icon: '🗑️', okText: 'Tak, usuń'});">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="msg-delete-btn" title="Usuń wiadomość">🗑</button>
</form>
{% endif %}
</div>
<div class="msg-content">{{ msg.content|linkify }}</div>
{% if msg.attachments %}
<div class="msg-attachments">
{% for att in msg.attachments %}
{% set is_image = att.mime_type and att.mime_type.startswith('image/') %}
<div class="msg-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: 16px;">📄</span>
<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);">{{ (att.file_size / 1024)|round(0)|int }} KB</span>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% if read_receipts and read_receipts.get(msg.id) %}
<div class="msg-read-receipts">
{% for reader in read_receipts[msg.id] %}
<div class="read-receipt" title="{{ reader.name or reader.email.split('@')[0] }}">
{% if reader.avatar_path %}
<img src="{{ url_for('static', filename=reader.avatar_path) }}" alt="">
{% else %}
<span>{{ (reader.name or reader.email)[0].upper() }}</span>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
{% else %}
<div class="empty-state">
<p>Brak wiadomosci w grupie</p>
</div>
{% endif %}
</div>
{# ===== REPLY FORM ===== #}
<div class="reply-section">
<h3>Napisz wiadomosc</h3>
<form method="POST" action="{{ url_for('messages.group_send', group_id=group.id) }}" enctype="multipart/form-data" id="reply-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<div id="quill-reply" class="quill-container" style="background: var(--surface);"></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 lacznie</span>
</div>
<button type="submit" class="btn btn-primary">Wyslij</button>
</form>
</div>
</div>
{% endblock %}
{% block extra_js %}
/* Members toggle */
(function() {
var toggle = document.getElementById('members-toggle');
var list = document.getElementById('members-list');
if (toggle && list) {
toggle.addEventListener('click', function() {
toggle.classList.toggle('open');
list.classList.toggle('visible');
});
}
})();
/* Auto-scroll to bottom */
(function() {
var section = document.getElementById('messages-section');
if (section && section.children.length > 0) {
var lastMsg = section.lastElementChild;
if (lastMsg) {
lastMsg.scrollIntoView({behavior: 'instant', block: 'end'});
}
}
})();
/* Quill reply editor */
(function() {
var replyDiv = document.getElementById('quill-reply');
if (!replyDiv) return;
var csrfToken = '{{ csrf_token() }}';
var quill = new Quill('#quill-reply', {
theme: 'snow',
placeholder: 'Napisz wiadomosc...',
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;
});
document.getElementById('reply-form').addEventListener('submit', function(e) {
var html = quill.root.innerHTML;
textarea.value = (html === '<p><br></p>') ? '' : html;
if (!textarea.value.trim()) {
e.preventDefault();
alert('Tresc wiadomosci jest wymagana.');
return;
}
var btn = this.querySelector('button[type="submit"]');
if (btn.disabled) { e.preventDefault(); return; }
btn.disabled = true;
btn.textContent = 'Wysylanie...';
});
})();
/* Auto-refresh: poll for new messages every 5 seconds */
(function() {
var section = document.getElementById('messages-section');
if (!section) return;
var allMsgs = section.querySelectorAll('.group-message');
var lastId = 0;
if (allMsgs.length > 0) {
// Extract max message ID from data attribute or count
lastId = {{ messages[-1].id if messages else 0 }};
}
function createMessageHtml(msg) {
var avatarHtml;
if (msg.sender_avatar) {
avatarHtml = '<a href="/osoba/' + msg.sender_id + '" style="text-decoration:none;flex-shrink:0;">' +
'<img src="' + msg.sender_avatar + '" class="msg-avatar" alt="">' +
'</a>';
} else {
avatarHtml = '<a href="/osoba/' + msg.sender_id + '" style="text-decoration:none;flex-shrink:0;">' +
'<div class="msg-initial' + (msg.is_me ? ' is-me' : '') + '">' + msg.sender_initial + '</div>' +
'</a>';
}
var senderHtml = msg.is_me ? 'Ty' :
'<a href="/osoba/' + msg.sender_id + '" style="color:inherit;text-decoration:none;" onmouseover="this.style.textDecoration=\'underline\'" onmouseout="this.style.textDecoration=\'none\'">' + msg.sender_name + '</a>';
var receiptsHtml = '';
if (msg.read_by && msg.read_by.length > 0) {
receiptsHtml = '<div class="msg-read-receipts">';
msg.read_by.forEach(function(r) {
if (r.avatar_url) {
receiptsHtml += '<div class="read-receipt" title="' + r.name + '"><img src="' + r.avatar_url + '" alt=""></div>';
} else {
receiptsHtml += '<div class="read-receipt" title="' + r.name + '"><span>' + r.initial + '</span></div>';
}
});
receiptsHtml += '</div>';
}
return '<div class="group-message" data-msg-id="' + msg.id + '">' +
avatarHtml +
'<div class="msg-bubble">' +
'<div class="msg-header">' +
'<span class="msg-sender">' + senderHtml + '</span>' +
'<span class="msg-time">' + msg.time + '</span>' +
'</div>' +
'<div class="msg-content">' + msg.content + '</div>' +
'</div>' +
receiptsHtml +
'</div>';
}
function pollMessages() {
fetch('/api/grupa/' + {{ group.id }} + '/nowe?after=' + lastId)
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.messages && data.messages.length > 0) {
// Remove old read receipts (they may have moved)
section.querySelectorAll('.msg-read-receipts').forEach(function(el) { el.remove(); });
data.messages.forEach(function(msg) {
section.insertAdjacentHTML('beforeend', createMessageHtml(msg));
lastId = msg.id;
});
// Scroll to bottom
section.scrollTop = section.scrollHeight;
}
})
.catch(function() { /* silent */ });
}
setInterval(pollMessages, 5000);
})();
{% endblock %}