feat: teams redesign — cards, filter, user autocomplete

- Teams index: cards with animation, search filter
- Team detail: user autocomplete via AJAX (search on type)
- CSS: card grid styles, autocomplete dropdown
- Backend: searchUsers endpoint, filter support
This commit is contained in:
2026-06-09 18:11:20 -04:00
parent b1fc0e2cfe
commit 13f14416a4
5 changed files with 419 additions and 75 deletions

View File

@@ -1,4 +1,4 @@
<?php $teams = $teams ?? []; ?>
<?php $teams = $teams ?? []; $search = $search ?? ''; ?>
<div class="page-header">
<div class="page-header-left">
@@ -12,62 +12,79 @@
</div>
</div>
<div class="card">
<div class="card-body">
<?php if (empty($teams)): ?>
<div class="empty-state">
<i class="fas fa-users-cog"></i>
<p>No teams created yet.</p>
<a href="/teams/create" class="btn btn-primary">Create Your First Team</a>
</div>
<?php else: ?>
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Members</th>
<th>Servers</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($teams as $team): ?>
<tr>
<td>
<a href="/teams/<?= $team['id'] ?>" class="fw-semibold">
<?= htmlspecialchars($team['name']) ?>
</a>
</td>
<td class="text-muted"><?= htmlspecialchars(mb_substr($team['description'] ?? '', 0, 80)) ?></td>
<td><span class="badge badge-info"><?= (int) ($team['member_count'] ?? 0) ?></span></td>
<td><span class="badge badge-primary"><?= (int) ($team['server_count'] ?? 0) ?></span></td>
<td>
<div class="btn-group">
<a href="/teams/<?= $team['id'] ?>" class="btn btn-xs" title="Manage">
<i class="fas fa-cog"></i>
</a>
<button class="btn btn-xs btn-danger" title="Delete"
onclick="deleteTeam(<?= $team['id'] ?>, '<?= htmlspecialchars(addslashes($team['name'])) ?>')">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="teams-toolbar">
<div class="teams-search">
<i class="fas fa-search teams-search-icon"></i>
<input type="text" id="teamSearch" class="form-input teams-search-input"
placeholder="Search teams..." value="<?= htmlspecialchars($search) ?>">
<?php if ($search !== ''): ?>
<button class="teams-search-clear" onclick="clearSearch()">&times;</button>
<?php endif; ?>
</div>
</div>
<?php if (empty($teams)): ?>
<div class="empty-state">
<i class="fas fa-users-cog"></i>
<p><?= $search !== '' ? 'No teams match your search.' : 'No teams created yet.' ?></p>
<?php if ($search !== ''): ?>
<a href="/teams" class="btn btn-secondary">Clear Search</a>
<?php else: ?>
<a href="/teams/create" class="btn btn-primary">Create Your First Team</a>
<?php endif; ?>
</div>
<?php else: ?>
<div class="teams-grid" id="teamsGrid">
<?php foreach ($teams as $team): ?>
<div class="team-card">
<div class="team-card-header">
<div class="team-card-icon">
<i class="fas fa-users"></i>
</div>
<h3 class="team-card-title">
<a href="/teams/<?= $team['id'] ?>"><?= htmlspecialchars($team['name']) ?></a>
</h3>
</div>
<p class="team-card-desc">
<?= htmlspecialchars(mb_substr($team['description'] ?? 'No description', 0, 100)) ?>
</p>
<div class="team-card-stats">
<span class="team-stat">
<i class="fas fa-user"></i> <?= (int) ($team['member_count'] ?? 0) ?> members
</span>
<span class="team-stat">
<i class="fas fa-server"></i> <?= (int) ($team['server_count'] ?? 0) ?> servers
</span>
</div>
<div class="team-card-actions">
<a href="/teams/<?= $team['id'] ?>" class="btn btn-sm btn-primary">
<i class="fas fa-cog"></i> Manage
</a>
<button class="btn btn-sm btn-danger" title="Delete"
onclick="deleteTeam(<?= $team['id'] ?>, '<?= htmlspecialchars(addslashes($team['name'])) ?>')">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<form id="deleteForm" method="POST" style="display:none">
<input type="hidden" name="_csrf_token" value="<?= $this->csrfToken() ?>">
</form>
<script>
document.getElementById('teamSearch')?.addEventListener('input', function() {
const val = this.value.trim();
const url = val ? '/teams?search=' + encodeURIComponent(val) : '/teams';
window.location.href = url;
});
function clearSearch() {
window.location.href = '/teams';
}
function deleteTeam(id, name) {
if (confirm('Delete team "' + name + '"?\nThis will remove access for all members.')) {
const form = document.getElementById('deleteForm');

View File

@@ -2,7 +2,6 @@
$team = $team ?? [];
$members = $members ?? [];
$servers = $servers ?? [];
$availableUsers = $availableUsers ?? [];
$availableServers = $availableServers ?? [];
?>
@@ -39,7 +38,7 @@ $availableServers = $availableServers ?? [];
<th>Actions</th>
</tr>
</thead>
<tbody>
<tbody id="membersList">
<?php foreach ($members as $member): ?>
<tr>
<td>
@@ -58,24 +57,16 @@ $availableServers = $availableServers ?? [];
</table>
</div>
<?php if (!empty($availableUsers)): ?>
<form class="form-inline" style="margin-top:1rem;display:flex;gap:0.5rem;align-items:end" onsubmit="addMember(event)">
<input type="hidden" name="_csrf_token" value="<?= $this->csrfToken() ?>">
<div class="form-group" style="flex:1;margin-bottom:0">
<select id="addUserId" class="form-select" required>
<option value="">Add user...</option>
<?php foreach ($availableUsers as $user): ?>
<option value="<?= $user['id'] ?>">
<?= htmlspecialchars($user['username']) ?> (<?= htmlspecialchars($user['email']) ?>)
</option>
<?php endforeach; ?>
</select>
<div class="autocomplete-wrapper" style="margin-top:1rem">
<label class="form-label">Add Member</label>
<div class="autocomplete" id="userAutocomplete">
<input type="text" class="form-input autocomplete-input"
id="addUserInput" placeholder="Search users..."
autocomplete="off">
<div class="autocomplete-dropdown" id="userDropdown"></div>
</div>
<button type="submit" class="btn btn-primary btn-sm">
<i class="fas fa-plus"></i> Add
</button>
</form>
<?php endif; ?>
<small class="form-hint">Type at least 2 characters to search users.</small>
</div>
</div>
</div>
@@ -187,13 +178,66 @@ function deleteTeam() {
document.getElementById('postForm').submit();
}
function addMember(e) {
e.preventDefault();
const userId = document.getElementById('addUserId').value;
if (!userId) return;
/* ───── Autocomplete para usuarios ───── */
let searchTimeout = null;
let selectedUserId = null;
document.getElementById('addUserInput').addEventListener('input', function() {
clearTimeout(searchTimeout);
const q = this.value.trim();
selectedUserId = null;
if (q.length < 2) {
document.getElementById('userDropdown').classList.remove('active');
return;
}
searchTimeout = setTimeout(function() {
fetch('/teams/search-users?q=' + encodeURIComponent(q), {
headers: { 'X-CSRF-Token': getCsrfToken() }
})
.then(r => r.json())
.then(data => {
const dropdown = document.getElementById('userDropdown');
dropdown.innerHTML = '';
if (data.users && data.users.length > 0) {
data.users.forEach(function(u) {
const item = document.createElement('div');
item.className = 'autocomplete-item';
item.innerHTML = '<strong>' + escapeHtml(u.username) + '</strong> <small>' + escapeHtml(u.email) + '</small>';
item.dataset.id = u.id;
item.dataset.username = u.username;
item.addEventListener('click', function() {
selectUser(u.id, u.username);
});
dropdown.appendChild(item);
});
dropdown.classList.add('active');
} else {
dropdown.innerHTML = '<div class="autocomplete-empty">No users found</div>';
dropdown.classList.add('active');
}
})
.catch(function() {
document.getElementById('userDropdown').classList.remove('active');
});
}, 300);
});
document.addEventListener('click', function(e) {
if (!e.target.closest('.autocomplete')) {
document.getElementById('userDropdown').classList.remove('active');
}
});
function selectUser(id, username) {
selectedUserId = id;
document.getElementById('addUserInput').value = username;
document.getElementById('userDropdown').classList.remove('active');
// Auto-add the user
const form = new FormData();
form.append('user_id', userId);
form.append('user_id', id);
form.append('_csrf_token', getCsrfToken());
fetch('/teams/<?= $team['id'] ?>/add-user', {
@@ -203,11 +247,17 @@ function addMember(e) {
})
.then(r => r.json())
.then(res => {
if (res.success) { showToast('success', res.message); location.reload(); }
else { showToast('error', res.message); }
if (res.success) {
showToast('success', username + ' added to team');
location.reload();
} else {
showToast('error', res.message);
document.getElementById('addUserInput').value = '';
}
});
}
/* ───── Funciones existentes ───── */
function removeMember(userId, username) {
if (!confirm('Remove "' + username + '" from this team?')) return;
@@ -286,4 +336,10 @@ function updateServerRole(serverId, role) {
else { showToast('error', res.message); }
});
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
</script>