This commit is contained in:
2026-09-13 04:05:59 +02:00
parent 90860b9279
commit 340c825019
48 changed files with 2727 additions and 532 deletions
+265 -17
View File
@@ -87,6 +87,99 @@
.btn-role { background: rgba(180, 100, 255, 0.15); color: #c084fc; border: 1px solid rgba(180, 100, 255, 0.3); }
.btn-role:hover { background: rgba(180, 100, 255, 0.25); opacity: 1; }
.legacy-filter-btn {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 0 14px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
font-size: 0.85rem;
color: #aaa;
white-space: nowrap;
user-select: none;
transition: all 0.2s ease;
height: 42px;
box-sizing: border-box;
}
.legacy-filter-btn:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.2);
color: #fff;
}
.legacy-filter-btn.active {
background: rgba(var(--accent-rgb, 0, 150, 255), 0.15);
border-color: var(--accent);
color: var(--accent);
}
.legacy-filter-btn input {
cursor: pointer;
accent-color: var(--accent);
width: 15px;
height: 15px;
margin: 0;
}
.admin-select-filter {
padding: 0 14px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: #ccc;
font-size: 0.85rem;
outline: none;
cursor: pointer;
transition: all 0.2s ease;
height: 42px;
box-sizing: border-box;
}
.admin-select-filter:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.2);
color: #fff;
}
.admin-select-filter:focus {
border-color: var(--accent);
}
.admin-select-filter.active {
background: rgba(var(--accent-rgb, 0, 150, 255), 0.15);
border-color: var(--accent);
color: var(--accent);
font-weight: 600;
}
.admin-select-filter option {
background: #1a1a1a;
color: #fff;
}
.admin-select-filter:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.btn-filter-reset {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #aaa;
padding: 0 14px;
height: 42px;
border-radius: 8px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.85rem;
transition: all 0.2s;
box-sizing: border-box;
white-space: nowrap;
}
.btn-filter-reset:hover {
background: rgba(255, 60, 60, 0.15);
border-color: rgba(255, 60, 60, 0.3);
color: #ff6b6b;
}
/* Create User Modal */
#create-user-modal {
display: none;
@@ -236,28 +329,60 @@
</div>
<div style="padding: 0 15px;">
<div style="display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 30px; gap: 20px; flex-wrap: wrap;">
<div style="display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 25px; gap: 20px; flex-wrap: wrap;">
<div>
<h2 style="margin: 0; font-weight: 800; letter-spacing: -0.5px;">User Management</h2>
<p style="color: #888; margin: 5px 0 0 0;">Administration hub for <span id="total-count">{!! total !!}</span> registered members.</p>
<p style="color: #888; margin: 5px 0 0 0;">Administration hub for <span id="total-count">{!! total !!}</span> <span id="total-label">{{ totalLabel }}</span>.</p>
</div>
<div style="display: flex; gap: 10px; align-items: center; flex-grow: 1; max-width: 500px;">
<div style="flex-grow: 1; position: relative;">
<input type="text" id="user-search" placeholder='Search by name or email… use "exact" for exact match'
style="width: 100%; padding: 12px 20px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; color: #fff; outline: none; transition: border-color 0.2s;"
value="{{ q }}">
<div id="search-spinner" style="position: absolute; right: 15px; top: 12px; display: none;">
<svg class="spinner" width="20" height="20" viewBox="0 0 50 50" style="animation: rotate 2s linear infinite;">
<circle cx="25" cy="25" r="20" fill="none" stroke="var(--accent)" stroke-width="5" stroke-dasharray="90,150" stroke-dashoffset="0" style="stroke-linecap: round;"></circle>
</svg>
</div>
</div>
<button class="btn-modern btn-create" onclick="openCreateUserModal()" style="padding: 10px 16px; font-size: 0.85rem; white-space: nowrap;" title="Create new user">
<div>
<button class="btn-modern btn-create" onclick="openCreateUserModal()" style="padding: 11px 18px; font-size: 0.85rem; white-space: nowrap; height: 42px;" title="Create new user">
<i class="fa fa-user-plus"></i> Create User
</button>
</div>
</div>
<!-- Filter & Search Toolbar -->
<div style="display: flex; gap: 10px; align-items: center; margin-bottom: 25px; flex-wrap: wrap;">
<div style="flex-grow: 1; min-width: 240px; position: relative;">
<input type="text" id="user-search" placeholder='Search by name or email… use "exact" for exact match'
style="width: 100%; height: 42px; padding: 0 20px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; color: #fff; outline: none; transition: border-color 0.2s; box-sizing: border-box;"
value="{{ q }}">
<div id="search-spinner" style="position: absolute; right: 15px; top: 11px; display: none;">
<svg class="spinner" width="20" height="20" viewBox="0 0 50 50" style="animation: rotate 2s linear infinite;">
<circle cx="25" cy="25" r="20" fill="none" stroke="var(--accent)" stroke-width="5" stroke-dasharray="90,150" stroke-dashoffset="0" style="stroke-linecap: round;"></circle>
</svg>
</div>
</div>
<!-- Status Filter -->
<select id="status-filter" class="admin-select-filter {{ status ? 'active' : '' }}" {{ onlyLegacy ? 'disabled' : '' }} title="Filter by account status">
<option value="">All Statuses</option>
<option value="banned" {{ status === 'banned' ? 'selected' : '' }}>⛔ Banned</option>
<option value="active" {{ status === 'active' ? 'selected' : '' }}>🟢 Active</option>
<option value="pending" {{ status === 'pending' ? 'selected' : '' }}>⏳ Pending</option>
</select>
<!-- Role Filter -->
<select id="role-filter" class="admin-select-filter {{ role ? 'active' : '' }}" {{ onlyLegacy ? 'disabled' : '' }} title="Filter by user role">
<option value="">All Roles</option>
<option value="staff" {{ role === 'staff' ? 'selected' : '' }}>🛡️ Staff (All)</option>
<option value="admin" {{ role === 'admin' ? 'selected' : '' }}>⚡ Admins</option>
<option value="mod" {{ role === 'mod' ? 'selected' : '' }}>🗡️ Moderators</option>
<option value="user" {{ role === 'user' ? 'selected' : '' }}>👤 Regular Users</option>
</select>
<!-- Legacy Only Toggle -->
<label id="legacy-filter-label" class="legacy-filter-btn {{ onlyLegacy ? 'active' : '' }}" title="Toggle to only show legacy ghost users">
<input type="checkbox" id="legacy-filter" {{ onlyLegacy ? 'checked' : '' }}>
<span><i class="fa fa-ghost" style="opacity: 0.8; margin-right: 4px;"></i>Only legacy</span>
</label>
<!-- Reset Filters Button -->
<button id="reset-filters-btn" class="btn-filter-reset" style="display: {{ (q || status || role || onlyLegacy) ? 'inline-flex' : 'none' }};" title="Reset all filters and search">
<i class="fa fa-rotate-left"></i> Reset
</button>
</div>
<div class="upload-form">
<table class="admin-users-table responsive-table">
<thead>
@@ -276,7 +401,7 @@
</table>
<div id="loading-trigger" style="height: 20px; margin-top: 10px;"></div>
<div id="no-users-msg" style="display: {{ users.length === 0 ? 'block' : 'none' }}; padding: 40px; text-align: center; color: #666;">
No users matched your search.
{{ emptyMsg }}
</div>
</div>
</div>
@@ -502,28 +627,81 @@
var hasMore = {!! hasMore ? 'true' : 'false' !!};
var isLoading = false;
var searchQuery = '{{ q }}';
var onlyLegacy = {!! onlyLegacy ? 'true' : 'false' !!};
var currentStatus = '{{ status }}';
var currentRole = '{{ role }}';
var searchInput = document.getElementById('user-search');
var statusFilter = document.getElementById('status-filter');
var roleFilter = document.getElementById('role-filter');
var legacyFilter = document.getElementById('legacy-filter');
var legacyFilterLabel = document.getElementById('legacy-filter-label');
var resetFiltersBtn = document.getElementById('reset-filters-btn');
var totalLabel = document.getElementById('total-label');
var tableBody = document.getElementById('user-table-body');
var loadingTrigger = document.getElementById('loading-trigger');
var noUsersMsg = document.getElementById('no-users-msg');
var spinner = document.getElementById('search-spinner');
var countSpan = document.getElementById('total-count');
function updateUrlAndControls() {
var urlParams = new URLSearchParams();
if (searchQuery) urlParams.set('q', searchQuery);
if (onlyLegacy) {
urlParams.set('legacy', '1');
} else {
if (currentStatus) urlParams.set('status', currentStatus);
if (currentRole) urlParams.set('role', currentRole);
}
var newUrl = window.location.pathname + (urlParams.toString() ? ('?' + urlParams.toString()) : '');
window.history.replaceState({}, '', newUrl);
// Update reset button
var hasFilter = !!(searchQuery || onlyLegacy || currentStatus || currentRole);
if (resetFiltersBtn) resetFiltersBtn.style.display = hasFilter ? 'inline-flex' : 'none';
// Update control states
if (statusFilter) {
statusFilter.disabled = onlyLegacy;
statusFilter.classList.toggle('active', !!currentStatus && !onlyLegacy);
}
if (roleFilter) {
roleFilter.disabled = onlyLegacy;
roleFilter.classList.toggle('active', !!currentRole && !onlyLegacy);
}
if (legacyFilterLabel) {
legacyFilterLabel.classList.toggle('active', onlyLegacy);
}
}
async function fetchUsers(page, q, append) {
if (isLoading) return;
isLoading = true;
if (!append) spinner.style.display = 'block';
try {
var url = '/admin/users?page=' + page + '&q=' + encodeURIComponent(q);
var params = new URLSearchParams();
params.set('page', page);
if (q) params.set('q', q);
if (onlyLegacy) {
params.set('legacy', '1');
} else {
if (currentStatus) params.set('status', currentStatus);
if (currentRole) params.set('role', currentRole);
}
var url = '/admin/users?' + params.toString();
var res = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
// Update state from headers
var total = res.headers.get('X-Total-Count');
var hasMoreHeader = res.headers.get('X-Has-More');
var totalLabelHeader = res.headers.get('X-Total-Label');
var emptyMsgHeader = res.headers.get('X-Empty-Msg');
if (total !== null) countSpan.textContent = total;
if (totalLabelHeader && totalLabel) totalLabel.textContent = totalLabelHeader;
if (emptyMsgHeader && noUsersMsg) noUsersMsg.textContent = emptyMsgHeader;
if (hasMoreHeader !== null) hasMore = (hasMoreHeader === 'true');
var html = await res.text();
@@ -548,12 +726,74 @@
searchInput.addEventListener('input', function() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(function() {
searchQuery = searchInput.value;
searchQuery = searchInput.value.trim();
currentPage = 1;
updateUrlAndControls();
fetchUsers(1, searchQuery, false);
}, 300);
});
// Status Filter Handling
if (statusFilter) {
statusFilter.addEventListener('change', function() {
currentStatus = this.value;
if (currentStatus && onlyLegacy) {
onlyLegacy = false;
if (legacyFilter) legacyFilter.checked = false;
}
updateUrlAndControls();
currentPage = 1;
fetchUsers(1, searchQuery, false);
});
}
// Role Filter Handling
if (roleFilter) {
roleFilter.addEventListener('change', function() {
currentRole = this.value;
if (currentRole && onlyLegacy) {
onlyLegacy = false;
if (legacyFilter) legacyFilter.checked = false;
}
updateUrlAndControls();
currentPage = 1;
fetchUsers(1, searchQuery, false);
});
}
// Legacy Only Filter Handling
if (legacyFilter) {
legacyFilter.addEventListener('change', function() {
onlyLegacy = this.checked;
if (onlyLegacy) {
currentStatus = '';
currentRole = '';
if (statusFilter) statusFilter.value = '';
if (roleFilter) roleFilter.value = '';
}
updateUrlAndControls();
currentPage = 1;
fetchUsers(1, searchQuery, false);
});
}
// Reset All Filters
if (resetFiltersBtn) {
resetFiltersBtn.addEventListener('click', function() {
searchQuery = '';
searchInput.value = '';
onlyLegacy = false;
currentStatus = '';
currentRole = '';
if (legacyFilter) legacyFilter.checked = false;
if (statusFilter) statusFilter.value = '';
if (roleFilter) roleFilter.value = '';
updateUrlAndControls();
currentPage = 1;
fetchUsers(1, '', false);
});
}
// Infinite Scroll
var observer = new IntersectionObserver(function(entries) {
if (entries[0].isIntersecting && hasMore && !isLoading) {
@@ -624,6 +864,14 @@
if (data.success) {
closeCreateUserModal();
showFlash(data.msg, 'success');
// If we were filtering by legacy or banned, reset filter so admin can see newly created user
if (onlyLegacy || currentStatus === 'banned') {
onlyLegacy = false;
currentStatus = '';
if (legacyFilter) legacyFilter.checked = false;
if (statusFilter) statusFilter.value = '';
updateUrlAndControls();
}
// Refresh the table from page 1
currentPage = 1;
fetchUsers(1, searchQuery, false);