nordabiz/templates/messages/group_manage.html
Maciej Pienczyn b5ef5b0b32
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: make user names and avatars clickable links to profiles
Add profile links to usernames and avatars across forum, classifieds,
announcements, company recommendations, board members, and group messages.

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

578 lines
21 KiB
HTML

{% extends "base.html" %}
{% block title %}Zarzadzanie grupa - {{ group.display_name }} - Norda Biznes Partner{% endblock %}
{% block extra_css %}
.manage-container {
max-width: 700px;
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);
}
.manage-header {
margin-bottom: var(--spacing-xl);
}
.manage-header h1 {
font-size: var(--font-size-2xl);
color: var(--text-primary);
}
.manage-card {
background: var(--surface);
border-radius: var(--radius-lg);
border: 1px solid var(--border-color, #e5e7eb);
padding: var(--spacing-xl);
margin-bottom: var(--spacing-lg);
}
.manage-card h2 {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--spacing-lg);
}
.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 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);
font-family: inherit;
}
.form-group textarea {
resize: vertical;
min-height: 80px;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
/* Members table */
.members-table {
width: 100%;
border-collapse: collapse;
}
.members-table th {
text-align: left;
padding: var(--spacing-sm) var(--spacing-md);
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 2px solid var(--border-color, #e5e7eb);
}
.members-table td {
padding: var(--spacing-sm) var(--spacing-md);
border-bottom: 1px solid var(--border-color, #f3f4f6);
vertical-align: middle;
}
.members-table tr:last-child td {
border-bottom: none;
}
.member-cell {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.member-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.member-initial {
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;
}
.member-info-name {
font-weight: 500;
font-size: var(--font-size-sm);
color: var(--text-primary);
}
.member-info-email {
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
.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;
}
.role-badge.member {
background: #f3f4f6;
color: #6b7280;
}
.action-buttons {
display: flex;
gap: var(--spacing-xs);
flex-wrap: wrap;
}
.btn-action {
padding: 4px 12px;
border-radius: var(--radius);
font-size: var(--font-size-xs);
font-weight: 500;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-secondary);
cursor: pointer;
transition: var(--transition);
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 4px;
}
.btn-action:hover {
background: var(--background);
color: var(--text-primary);
}
.btn-action.danger {
color: #dc2626;
border-color: #fecaca;
}
.btn-action.danger:hover {
background: #fef2f2;
border-color: #dc2626;
}
.btn-action.moderator-toggle {
color: var(--primary);
border-color: #bfdbfe;
}
.btn-action.moderator-toggle:hover {
background: #eff6ff;
}
/* Add member section */
.add-member-section {
margin-top: var(--spacing-lg);
padding-top: var(--spacing-lg);
border-top: 1px solid var(--border-color, #e5e7eb);
}
.add-member-section h3 {
font-size: var(--font-size-base);
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--spacing-md);
}
.add-member-autocomplete {
position: relative;
}
.add-member-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);
}
.add-member-autocomplete input[type="text"]:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.add-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;
}
.add-autocomplete-results.visible {
display: block;
}
.add-autocomplete-item {
padding: var(--spacing-sm) var(--spacing-md);
cursor: pointer;
display: flex;
align-items: center;
gap: var(--spacing-sm);
transition: background 0.15s;
}
.add-autocomplete-item:hover {
background: var(--background);
}
.add-autocomplete-item-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.add-autocomplete-item-initial {
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;
}
.add-autocomplete-item-name {
font-weight: 500;
font-size: var(--font-size-sm);
}
.add-autocomplete-item-company {
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
.add-autocomplete-no-results {
padding: var(--spacing-sm) var(--spacing-md);
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
@media (max-width: 640px) {
.manage-card {
padding: var(--spacing-md);
}
.members-table th:last-child,
.members-table td:last-child {
text-align: right;
}
.action-buttons {
justify-content: flex-end;
}
}
{% endblock %}
{% block content %}
<div class="manage-container">
<a href="{{ url_for('messages.group_view', group_id=group.id) }}" 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 grupy
</a>
<div class="manage-header">
<h1>Zarzadzanie grupa</h1>
</div>
{# ===== GROUP INFO EDIT (owner only) ===== #}
{% if membership.is_owner %}
<div class="manage-card">
<h2>Informacje o grupie</h2>
<form method="POST" action="{{ url_for('messages.group_edit', group_id=group.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<label for="group-name">Nazwa grupy</label>
<input type="text" id="group-name" name="name" maxlength="100" value="{{ group.name or '' }}" placeholder="np. Komisja ds. PEJ">
</div>
<div class="form-group">
<label for="group-description">Opis (opcjonalnie)</label>
<textarea id="group-description" name="description" maxlength="500" placeholder="Krotki opis grupy...">{{ group.description or '' }}</textarea>
</div>
<button type="submit" class="btn btn-primary">Zapisz zmiany</button>
</form>
<div style="margin-top: var(--spacing-xl); padding-top: var(--spacing-lg); border-top: 1px solid var(--border-color, #e5e7eb);">
<h3 style="color: #dc2626; font-size: var(--font-size-sm); margin-bottom: var(--spacing-sm);">Strefa niebezpieczna</h3>
<p style="font-size: var(--font-size-xs); color: var(--text-secondary); margin-bottom: var(--spacing-sm);">Usunięcie grupy jest nieodwracalne. Wszystkie wiadomości i załączniki zostaną usunięte.</p>
<form method="POST" action="{{ url_for('messages.group_delete', group_id=group.id) }}" onsubmit="return nordaConfirm(this, 'Wszystkie wiadomości i załączniki zostaną trwale usunięte.', {title: 'Usunąć grupę?', icon: '🗑️', okText: 'Tak, usuń grupę'});">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn" style="background: #dc2626; color: white; border: none; font-size: var(--font-size-sm);">Usuń grupę</button>
</form>
</div>
</div>
{% endif %}
{# ===== MEMBERS TABLE ===== #}
<div class="manage-card">
<h2>Uczestnicy ({{ members|length }})</h2>
<table class="members-table">
<thead>
<tr>
<th>Osoba</th>
<th>Rola</th>
<th>Akcje</th>
</tr>
</thead>
<tbody>
{% for m in members %}
<tr>
<td>
<div class="member-cell">
<a href="{{ url_for('public.user_profile', user_id=m.user_id) }}" style="text-decoration:none;color:inherit;">
{% 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 %}
</a>
<div>
<div class="member-info-name"><a href="{{ url_for('public.user_profile', user_id=m.user_id) }}" style="color:inherit;text-decoration:none;" onmouseover="this.style.textDecoration='underline'" onmouseout="this.style.textDecoration='none'">{{ m.user.name or m.user.email.split('@')[0] }}</a></div>
{% if m.user.privacy_show_email != False %}
<div class="member-info-email">{{ m.user.email }}</div>
{% endif %}
</div>
</div>
</td>
<td>
{% if m.is_owner %}
<span class="role-badge owner">Wlasciciel</span>
{% elif m.is_moderator %}
<span class="role-badge moderator">Moderator</span>
{% else %}
<span class="role-badge member">Uczestnik</span>
{% endif %}
</td>
<td>
<div class="action-buttons">
{% if m.user_id != current_user.id and not m.is_owner %}
{% if membership.is_owner %}
{# Owner can toggle moderator and remove #}
<form method="POST" action="{{ url_for('messages.group_change_role', group_id=group.id) }}" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="user_id" value="{{ m.user_id }}">
<input type="hidden" name="role" value="{{ 'member' if m.is_moderator else 'moderator' }}">
<button type="submit" class="btn-action moderator-toggle" title="{% if m.is_moderator %}Odbierz moderatora{% else %}Nadaj moderatora{% endif %}">
{% if m.is_moderator %}
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M18 6L6 18M6 6l12 12"/></svg>
Odbierz mod.
{% else %}
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg>
Nadaj mod.
{% endif %}
</button>
</form>
<form method="POST" action="{{ url_for('messages.group_remove_member', group_id=group.id) }}" style="display:inline;" onsubmit="return nordaConfirm(this, 'Uczestnik zostanie usunięty z grupy.', {title: 'Usunąć uczestnika?', icon: '👤', okText: 'Tak, usuń'});">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="user_id" value="{{ m.user_id }}">
<button type="submit" class="btn-action danger">
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
Usun
</button>
</form>
{% elif membership.is_moderator and not m.is_moderator %}
{# Moderator can remove members only #}
<form method="POST" action="{{ url_for('messages.group_remove_member', group_id=group.id) }}" style="display:inline;" onsubmit="return nordaConfirm(this, 'Uczestnik zostanie usunięty z grupy.', {title: 'Usunąć uczestnika?', icon: '👤', okText: 'Tak, usuń'});">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="user_id" value="{{ m.user_id }}">
<button type="submit" class="btn-action danger">
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
Usun
</button>
</form>
{% endif %}
{% elif m.user_id == current_user.id %}
<span style="font-size: var(--font-size-xs); color: var(--text-secondary);">Ty</span>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{# ===== ADD MEMBER ===== #}
<div class="add-member-section">
<h3>Dodaj uczestnika</h3>
<div class="add-member-autocomplete" id="add-member-autocomplete">
<input type="text" id="add-member-search" placeholder="Wpisz imie, nazwisko lub email..." autocomplete="off">
<div id="add-autocomplete-results" class="add-autocomplete-results"></div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
/* Add member autocomplete */
(function() {
var availableUsers = [
{% for user in available_users %}
{id: {{ user.id }}, name: {{ (user.name or user.email.split('@')[0]) | tojson }}, email: {{ user.email | tojson }}, companyName: {{ (user._company_name or '') | tojson }}, avatarPath: {% if user.avatar_path %}{{ url_for('static', filename=user.avatar_path) | tojson }}{% else %}""{% endif %}}{{ ',' if not loop.last }}
{% endfor %}
];
var searchInput = document.getElementById('add-member-search');
var resultsDiv = document.getElementById('add-autocomplete-results');
var autocompleteDiv = document.getElementById('add-member-autocomplete');
var groupId = {{ group.id }};
var csrfToken = '{{ csrf_token() }}';
function normalize(str) {
return str.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
}
function filterUsers(query) {
if (!query) return [];
var q = normalize(query);
return availableUsers.filter(function(u) {
return normalize(u.name).indexOf(q) !== -1 ||
normalize(u.email).indexOf(q) !== -1 ||
normalize(u.companyName).indexOf(q) !== -1;
});
}
function renderResults(matches) {
if (matches.length === 0) {
resultsDiv.innerHTML = '<div class="add-autocomplete-no-results">Nie znaleziono</div>';
resultsDiv.classList.add('visible');
return;
}
resultsDiv.innerHTML = matches.map(function(u) {
var initial = (u.name || u.email)[0].toUpperCase();
var avatarHtml = u.avatarPath
? '<img src="' + u.avatarPath + '" class="add-autocomplete-item-avatar" alt="">'
: '<div class="add-autocomplete-item-initial">' + initial + '</div>';
var companyHtml = u.companyName ? '<div class="add-autocomplete-item-company">' + u.companyName + '</div>' : '';
return '<div class="add-autocomplete-item" data-id="' + u.id + '">' +
avatarHtml +
'<div>' +
'<div class="add-autocomplete-item-name">' + u.name + '</div>' +
companyHtml +
'</div>' +
'</div>';
}).join('');
resultsDiv.classList.add('visible');
}
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('.add-autocomplete-item');
if (item) {
var userId = item.dataset.id;
/* Submit add member via hidden form */
var form = document.createElement('form');
form.method = 'POST';
form.action = '{{ url_for("messages.group_add_member", group_id=group.id) }}';
var csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrf_token';
csrfInput.value = csrfToken;
form.appendChild(csrfInput);
var userInput = document.createElement('input');
userInput.type = 'hidden';
userInput.name = 'user_id';
userInput.value = userId;
form.appendChild(userInput);
document.body.appendChild(form);
form.submit();
}
});
document.addEventListener('click', function(e) {
if (!autocompleteDiv.contains(e.target)) {
resultsDiv.classList.remove('visible');
}
});
})();
{% endblock %}