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:
@@ -2840,3 +2840,234 @@ textarea.form-input {
|
||||
.nav-sub-item .status-dot-sm {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
Teams — Card Grid
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.teams-toolbar {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.teams-search {
|
||||
position: relative;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.teams-search-icon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.teams-search-input {
|
||||
padding-left: 36px !important;
|
||||
padding-right: 36px !important;
|
||||
}
|
||||
|
||||
.teams-search-clear {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.teams-search-clear:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.teams-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.team-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
animation: teamCardEnter 0.35s ease both;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.team-card:nth-child(1) { animation-delay: 0.00s; }
|
||||
.team-card:nth-child(2) { animation-delay: 0.05s; }
|
||||
.team-card:nth-child(3) { animation-delay: 0.10s; }
|
||||
.team-card:nth-child(4) { animation-delay: 0.15s; }
|
||||
.team-card:nth-child(5) { animation-delay: 0.20s; }
|
||||
.team-card:nth-child(6) { animation-delay: 0.25s; }
|
||||
.team-card:nth-child(7) { animation-delay: 0.30s; }
|
||||
.team-card:nth-child(8) { animation-delay: 0.35s; }
|
||||
.team-card:nth-child(9) { animation-delay: 0.40s; }
|
||||
.team-card:nth-child(10) { animation-delay: 0.45s; }
|
||||
|
||||
.team-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
@keyframes teamCardEnter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(15px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.team-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.team-card-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
background: var(--accent-light);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--accent);
|
||||
font-size: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.team-card-title {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.team-card-title a {
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.team-card-title a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.team-card-desc {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.team-card-stats {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.team-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.team-stat i {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.team-card-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
Autocomplete — User Search
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.autocomplete {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.autocomplete-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.autocomplete-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0 0 8px 8px;
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
display: none;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.autocomplete-dropdown.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.autocomplete-item {
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.autocomplete-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.autocomplete-item:hover {
|
||||
background: var(--accent-light);
|
||||
}
|
||||
|
||||
.autocomplete-item strong {
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.autocomplete-item small {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.autocomplete-empty {
|
||||
padding: 14px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 0.35rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ $router->get('/servers/:id/metrics', [ServerController::class, 'metrics'], [Auth
|
||||
$router->get('/sidebar/servers', [ServerController::class, 'sidebarServers'], [AuthMiddleware::class]);
|
||||
|
||||
$router->get('/teams', [TeamController::class, 'index'], [AuthMiddleware::class, RoleMiddleware::class]);
|
||||
$router->get('/teams/search-users', [TeamController::class, 'searchUsers'], [AuthMiddleware::class, RoleMiddleware::class]);
|
||||
$router->get('/teams/create', [TeamController::class, 'create'], [AuthMiddleware::class, RoleMiddleware::class]);
|
||||
$router->post('/teams/create', [TeamController::class, 'store'], [AuthMiddleware::class, CSRFMiddleware::class, RoleMiddleware::class]);
|
||||
$router->get('/teams/:id', [TeamController::class, 'show'], [AuthMiddleware::class, RoleMiddleware::class]);
|
||||
|
||||
@@ -30,14 +30,53 @@ class TeamController
|
||||
public function index(): void
|
||||
{
|
||||
$userId = (int) Session::get('user_id');
|
||||
$teams = $this->teamModel->findByCreator($userId);
|
||||
$search = $_GET['search'] ?? '';
|
||||
|
||||
if ($search !== '') {
|
||||
$teams = $this->teamModel->findByCreator($userId);
|
||||
$teams = array_filter($teams, function ($team) use ($search) {
|
||||
$s = mb_strtolower($search);
|
||||
return mb_strpos(mb_strtolower($team['name'] ?? ''), $s) !== false
|
||||
|| mb_strpos(mb_strtolower($team['description'] ?? ''), $s) !== false;
|
||||
});
|
||||
} else {
|
||||
$teams = $this->teamModel->findByCreator($userId);
|
||||
}
|
||||
|
||||
$this->view->display('teams.index', [
|
||||
'title' => 'Teams - ServerManager',
|
||||
'teams' => $teams,
|
||||
'search' => $search,
|
||||
]);
|
||||
}
|
||||
|
||||
public function searchUsers(): void
|
||||
{
|
||||
$q = $_GET['q'] ?? '';
|
||||
if (mb_strlen($q) < 2) {
|
||||
$this->view->json(['users' => []]);
|
||||
return;
|
||||
}
|
||||
|
||||
$userModel = new User();
|
||||
$allUsers = $userModel->getAll(1, 999);
|
||||
$filtered = array_filter($allUsers['data'], function ($u) use ($q) {
|
||||
$s = mb_strtolower($q);
|
||||
return mb_strpos(mb_strtolower($u['username'] ?? ''), $s) !== false
|
||||
|| mb_strpos(mb_strtolower($u['email'] ?? ''), $s) !== false;
|
||||
});
|
||||
|
||||
$results = array_map(function ($u) {
|
||||
return [
|
||||
'id' => (int) $u['id'],
|
||||
'username' => $u['username'],
|
||||
'email' => $u['email'],
|
||||
];
|
||||
}, array_slice(array_values($filtered), 0, 20));
|
||||
|
||||
$this->view->json(['users' => $results]);
|
||||
}
|
||||
|
||||
public function create(): void
|
||||
{
|
||||
$this->view->display('teams.create', [
|
||||
|
||||
@@ -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()">×</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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user