improve(messages): add group management panel (members, rename, add/remove)
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

When viewing a group conversation, a settings button appears in the chat
header. Clicking it opens a panel to: rename the group (owner only),
view members with roles, remove members (owner only), and add new members
via search. Uses existing API endpoints.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-04-08 16:37:29 +02:00
parent ca75468367
commit b626e4b76d
3 changed files with 462 additions and 2 deletions

View File

@ -1664,3 +1664,153 @@ mark.search-highlight {
border-radius: var(--conv-radius); border-radius: var(--conv-radius);
} }
} }
/* ============================================================
* GROUP SETTINGS BUTTON & MANAGEMENT PANEL
* ============================================================ */
.btn-group-settings {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: none;
border-radius: var(--conv-radius);
background: transparent;
color: var(--conv-text-secondary);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.btn-group-settings:hover {
background: var(--conv-surface-secondary);
color: var(--conv-primary);
}
.group-panel {
background: var(--conv-surface);
border-bottom: 1px solid var(--conv-border);
max-height: 50vh;
overflow-y: auto;
}
.group-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--conv-border);
}
.group-panel-header h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
}
.group-panel-body {
padding: 12px 16px;
}
.group-panel-section {
margin-bottom: 14px;
}
.group-panel-section:last-child {
margin-bottom: 0;
}
.group-panel-section label {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--conv-text-secondary);
margin-bottom: 6px;
}
.group-name-row {
display: flex;
gap: 8px;
}
.group-name-row input {
flex: 1;
padding: 7px 10px;
border: 1px solid var(--conv-border);
border-radius: var(--conv-radius);
font-size: 13px;
outline: none;
}
.group-name-row input:focus {
border-color: var(--conv-primary);
}
.btn-sm {
padding: 6px 14px !important;
font-size: 12px !important;
}
.group-members-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.group-member-item {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px;
border-radius: var(--conv-radius);
}
.group-member-item:hover {
background: var(--conv-surface-secondary);
}
.group-member-info {
flex: 1;
min-width: 0;
}
.group-member-name {
font-size: 13px;
font-weight: 500;
}
.group-member-role {
font-size: 11px;
color: var(--conv-text-muted);
}
.group-member-actions {
display: flex;
gap: 4px;
}
.btn-member-action {
padding: 3px 8px;
font-size: 11px;
border: 1px solid var(--conv-border);
border-radius: 4px;
background: transparent;
color: var(--conv-text-secondary);
cursor: pointer;
transition: all 0.15s;
}
.btn-member-action:hover {
background: var(--conv-surface-secondary);
}
.btn-member-action.danger {
color: #d32f2f;
border-color: #d32f2f;
}
.btn-member-action.danger:hover {
background: #fce4ec;
}
#groupAddMemberSearch {
width: 100%;
padding: 7px 10px;
border: 1px solid var(--conv-border);
border-radius: var(--conv-radius);
font-size: 13px;
outline: none;
box-sizing: border-box;
}
#groupAddMemberSearch:focus {
border-color: var(--conv-primary);
}

View File

@ -289,6 +289,7 @@
var headerName = document.getElementById('headerName'); var headerName = document.getElementById('headerName');
if (headerName) headerName.textContent = conv.display_name || conv.name || 'Bez nazwy'; if (headerName) headerName.textContent = conv.display_name || conv.name || 'Bez nazwy';
ChatView.updateHeaderAvatar(conv); ChatView.updateHeaderAvatar(conv);
GroupManager.updateSettingsButton(conv);
} }
// Load conversation details + messages // Load conversation details + messages
@ -2441,6 +2442,280 @@
}, },
}; };
// ============================================================
// 10c. GROUP MANAGEMENT PANEL
// ============================================================
var GroupManager = {
_visible: false,
_debounceTimer: null,
init: function () {
var settingsBtn = document.getElementById('groupSettingsBtn');
var closeBtn = document.getElementById('closeGroupPanel');
var saveNameBtn = document.getElementById('saveGroupName');
var addSearch = document.getElementById('groupAddMemberSearch');
if (settingsBtn) {
settingsBtn.addEventListener('click', function () {
GroupManager.toggle();
});
}
if (closeBtn) {
closeBtn.addEventListener('click', function () {
GroupManager.hide();
});
}
if (saveNameBtn) {
saveNameBtn.addEventListener('click', function () {
GroupManager.saveName();
});
}
if (addSearch) {
addSearch.addEventListener('input', function () {
clearTimeout(GroupManager._debounceTimer);
GroupManager._debounceTimer = setTimeout(function () {
GroupManager.searchNewMember(addSearch.value);
}, 200);
});
}
},
show: function () {
var panel = document.getElementById('groupPanel');
if (!panel) return;
GroupManager._visible = true;
panel.style.display = 'block';
GroupManager.loadDetails();
},
hide: function () {
var panel = document.getElementById('groupPanel');
if (!panel) return;
GroupManager._visible = false;
panel.style.display = 'none';
},
toggle: function () {
if (GroupManager._visible) {
GroupManager.hide();
} else {
GroupManager.show();
}
},
updateSettingsButton: function (conv) {
var btn = document.getElementById('groupSettingsBtn');
if (!btn) return;
btn.style.display = conv && conv.is_group ? '' : 'none';
// Hide panel when switching conversations
GroupManager.hide();
},
loadDetails: async function () {
var convId = state.currentConversation;
if (!convId) return;
var details = state.conversationDetails[convId];
if (!details) {
try {
details = await api('/api/conversations/' + convId);
state.conversationDetails[convId] = details;
} catch (e) { return; }
}
// Name
var nameInput = document.getElementById('groupEditName');
if (nameInput) nameInput.value = details.name || '';
// Owner check
var isOwner = details.members.some(function (m) {
return m.user_id === window.__CURRENT_USER__.id && m.role === 'owner';
});
// Save name — show only for owner
var saveBtn = document.getElementById('saveGroupName');
if (saveBtn) saveBtn.style.display = isOwner ? '' : 'none';
if (nameInput) nameInput.readOnly = !isOwner;
// Add member section — owner only
var addSection = document.getElementById('groupAddMemberSection');
if (addSection) addSection.style.display = isOwner ? '' : 'none';
// Members list
var countEl = document.getElementById('groupMemberCount');
if (countEl) countEl.textContent = details.members.length;
var listEl = document.getElementById('groupMembersList');
if (!listEl) return;
listEl.innerHTML = '';
details.members.forEach(function (m) {
var item = el('div', 'group-member-item');
var avatar = el('div', 'conv-avatar ' + avatarColor(m.name));
avatar.style.width = '30px';
avatar.style.height = '30px';
avatar.style.minWidth = '30px';
avatar.style.fontSize = '11px';
avatar.textContent = initials(m.name);
var info = el('div', 'group-member-info');
var nameEl = el('div', 'group-member-name', m.name);
info.appendChild(nameEl);
if (m.role === 'owner') {
var roleEl = el('span', 'group-member-role', ' (właściciel)');
nameEl.appendChild(roleEl);
}
if (m.company_name) {
var companyEl = el('div', 'group-member-role', m.company_name);
info.appendChild(companyEl);
}
item.appendChild(avatar);
item.appendChild(info);
// Actions (owner can remove others, anyone can see)
if (isOwner && m.user_id !== window.__CURRENT_USER__.id) {
var actions = el('div', 'group-member-actions');
var removeBtn = el('button', 'btn-member-action danger', 'Usuń');
removeBtn.addEventListener('click', function () {
GroupManager.removeMember(convId, m.user_id, m.name);
});
actions.appendChild(removeBtn);
item.appendChild(actions);
}
listEl.appendChild(item);
});
},
saveName: async function () {
var convId = state.currentConversation;
var nameInput = document.getElementById('groupEditName');
if (!convId || !nameInput) return;
var newName = nameInput.value.trim();
if (!newName) return;
try {
await api('/api/conversations/' + convId, 'PATCH', { name: newName });
// Update local state
var conv = state.conversations.find(function (c) { return c.id === convId; });
if (conv) {
conv.name = newName;
conv.display_name = newName;
}
if (state.conversationDetails[convId]) {
state.conversationDetails[convId].name = newName;
}
ConversationList.renderList();
var headerName = document.getElementById('headerName');
if (headerName) headerName.textContent = newName;
} catch (e) {
alert('Nie udało się zmienić nazwy: ' + e.message);
}
},
removeMember: async function (convId, userId, userName) {
if (!confirm('Czy na pewno chcesz usunąć ' + userName + ' z grupy?')) return;
try {
await api('/api/conversations/' + convId + '/members/' + userId, 'DELETE');
// Refresh details
delete state.conversationDetails[convId];
GroupManager.loadDetails();
// Update subtitle
ChatView.loadConversationDetails(convId);
} catch (e) {
alert('Nie udało się usunąć członka: ' + e.message);
}
},
searchNewMember: async function (query) {
var suggestions = document.getElementById('groupAddMemberSuggestions');
if (!suggestions) return;
suggestions.innerHTML = '';
query = (query || '').trim();
if (query.length < 2) return;
var convId = state.currentConversation;
var details = state.conversationDetails[convId];
var existingIds = details ? details.members.map(function (m) { return m.user_id; }) : [];
suggestions.innerHTML = '<div style="padding:8px 12px;color:var(--conv-text-muted);font-size:13px">Szukam...</div>';
try {
var resp = await fetch('/api/users/search?q=' + encodeURIComponent(query), {
headers: { 'X-CSRFToken': window.__CSRF_TOKEN__ },
});
if (!resp.ok) throw new Error('search failed');
var users = await resp.json();
suggestions.innerHTML = '';
var matches = users.filter(function (u) {
return existingIds.indexOf(u.id) === -1;
});
if (!matches.length) {
suggestions.innerHTML = '<div style="padding:8px 12px;color:var(--conv-text-muted);font-size:13px">Brak wyników</div>';
return;
}
matches.forEach(function (u) {
var item = el('div', 'suggestion-item');
item.style.padding = '8px 12px';
item.style.cursor = 'pointer';
item.style.display = 'flex';
item.style.alignItems = 'center';
item.style.gap = '8px';
item.style.borderBottom = '1px solid var(--conv-border)';
var avatar = el('div', 'conv-avatar ' + avatarColor(u.name));
avatar.style.width = '28px';
avatar.style.height = '28px';
avatar.style.minWidth = '28px';
avatar.style.fontSize = '10px';
avatar.textContent = initials(u.name);
var nameEl = el('div', '', u.name || u.email);
nameEl.style.fontSize = '13px';
item.appendChild(avatar);
item.appendChild(nameEl);
item.addEventListener('click', function () {
GroupManager.addMember(convId, u.id, u.name);
});
item.addEventListener('mouseenter', function () { item.style.background = 'var(--conv-surface-secondary)'; });
item.addEventListener('mouseleave', function () { item.style.background = ''; });
suggestions.appendChild(item);
});
} catch (e) {
suggestions.innerHTML = '<div style="padding:8px 12px;color:var(--conv-text-muted);font-size:13px">Błąd wyszukiwania</div>';
}
},
addMember: async function (convId, userId, userName) {
try {
await api('/api/conversations/' + convId + '/members', 'POST', { user_id: userId });
// Clear search
var search = document.getElementById('groupAddMemberSearch');
if (search) search.value = '';
var suggestions = document.getElementById('groupAddMemberSuggestions');
if (suggestions) suggestions.innerHTML = '';
// Refresh
delete state.conversationDetails[convId];
GroupManager.loadDetails();
ChatView.loadConversationDetails(convId);
} catch (e) {
alert('Nie udało się dodać członka: ' + e.message);
}
},
};
// ============================================================ // ============================================================
// 11. SEARCH // 11. SEARCH
// ============================================================ // ============================================================
@ -2812,6 +3087,7 @@
Composer.init(); Composer.init();
NewMessageModal.init(); NewMessageModal.init();
NewGroupModal.init(); NewGroupModal.init();
GroupManager.init();
Search.init(); Search.init();
Pins.init(); Pins.init();
initContextMenu(); initContextMenu();

View File

@ -5,7 +5,7 @@
{% block head_extra %} {% block head_extra %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/quill.snow.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/quill.snow.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/conversations.css') }}?v=12"> <link rel="stylesheet" href="{{ url_for('static', filename='css/conversations.css') }}?v=13">
<script src="{{ url_for('static', filename='js/vendor/quill.js') }}"></script> <script src="{{ url_for('static', filename='js/vendor/quill.js') }}"></script>
<style> <style>
footer { display: none !important; } footer { display: none !important; }
@ -110,6 +110,14 @@
</div> </div>
</div> </div>
<div class="chat-header-actions"> <div class="chat-header-actions">
<button class="btn-group-settings" id="groupSettingsBtn" style="display:none" title="Zarządzaj grupą">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
</svg>
</button>
</div> </div>
</div> </div>
@ -119,6 +127,32 @@
<button id="togglePins">Pokaż</button> <button id="togglePins">Pokaż</button>
</div> </div>
<!-- Group management panel -->
<div class="group-panel" id="groupPanel" style="display:none">
<div class="group-panel-header">
<h3>Zarządzanie grupą</h3>
<button class="modal-close" id="closeGroupPanel">&times;</button>
</div>
<div class="group-panel-body">
<div class="group-panel-section">
<label>Nazwa grupy:</label>
<div class="group-name-row">
<input type="text" id="groupEditName" placeholder="Nazwa grupy...">
<button class="btn-primary btn-sm" id="saveGroupName">Zapisz</button>
</div>
</div>
<div class="group-panel-section">
<label>Członkowie (<span id="groupMemberCount">0</span>):</label>
<div id="groupMembersList" class="group-members-list"></div>
</div>
<div class="group-panel-section" id="groupAddMemberSection">
<label>Dodaj członka:</label>
<input type="text" id="groupAddMemberSearch" placeholder="Wpisz imię lub nazwisko...">
<div class="recipient-suggestions" id="groupAddMemberSuggestions"></div>
</div>
</div>
</div>
<!-- Messages area --> <!-- Messages area -->
<div class="chat-messages" id="chatMessages" style="display:none"> <div class="chat-messages" id="chatMessages" style="display:none">
<!-- Rendered by JS --> <!-- Rendered by JS -->
@ -292,7 +326,7 @@ window.__CSRF_TOKEN__ = '{{ csrf_token() }}';
// Load conversations.js after data is set // Load conversations.js after data is set
(function() { (function() {
var s = document.createElement('script'); var s = document.createElement('script');
s.src = '{{ url_for("static", filename="js/conversations.js") }}?v=19'; s.src = '{{ url_for("static", filename="js/conversations.js") }}?v=20';
document.body.appendChild(s); document.body.appendChild(s);
})(); })();
{% endblock %} {% endblock %}