refactor: users table

hide uid/ips behind dropdown, AP uids break flow
stack username/email
allow range selection
This commit is contained in:
Barış Soner Uşaklı
2026-02-20 13:23:05 -05:00
parent 0424728f7e
commit e3d7abe086
3 changed files with 107 additions and 47 deletions

View File

@@ -55,6 +55,7 @@
"inactive.12-months": "12 months",
"users.uid": "uid",
"users.user-id": "User ID",
"users.username": "username",
"users.email": "email",
"users.no-email": "(no email)",
@@ -63,6 +64,7 @@
"users.validation-pending": "Validation Pending",
"users.validation-expired": "Validation Expired",
"users.ip": "IP",
"users.recent-ips": "Recent IPs",
"users.postcount": "postcount",
"users.reputation": "reputation",
"users.flags": "flags",

View File

@@ -142,10 +142,6 @@ define('admin/manage/users', [
unselectAll();
}
$('[component="user/select/all"]').on('click', function () {
$('.users-table [component="user/select/single"]').prop('checked', $(this).is(':checked'));
});
$('.manage-groups').on('click', function () {
const uids = getSelectedUids();
if (!uids.length) {
@@ -494,6 +490,10 @@ define('admin/manage/users', [
handleDelete('[[admin/manage/users:alerts.confirm-purge]]', '');
});
$('[component="user/select/all"]').on('click', function () {
$('.users-table [component="user/select/single"]').prop('checked', $(this).is(':checked'));
});
const tableEl = document.querySelector('.users-table');
const actionBtn = document.getElementById('action-dropdown');
tableEl.addEventListener('change', (e) => {
@@ -508,6 +508,57 @@ define('admin/manage/users', [
}
});
let lastSelectedUser;
$(tableEl).on('click', '[component="user/select/single"]', function (ev) {
function selectRange(clickedUserRow) {
function selectIndexRange(start, end, isChecked) {
if (start > end) {
const tmp = start;
start = end;
end = tmp;
}
const rows = $('.user-row');
for (let i = start; i <= end; i += 1) {
rows.eq(i).find('.form-check-input').prop('checked', isChecked).trigger('change');
}
}
if (!lastSelectedUser) {
lastSelectedUser = $('.user-row').first();
}
const isClickedSelected = clickedUserRow.find('[component="user/select/single"]').is(':checked');
const clickedIndex = clickedUserRow.index();
const lastIndex = lastSelectedUser.index();
selectIndexRange(clickedIndex, lastIndex, isClickedSelected);
}
const checkBox = $(this);
const userRow = checkBox.parents('.user-row');
if (ev.shiftKey) {
selectRange(userRow);
lastSelectedUser = userRow;
return true;
}
lastSelectedUser = userRow;
});
$('[data-copy]').on('click', function () {
const btn = $(this);
navigator.clipboard.writeText(this.getAttribute('data-copy'));
btn.find('i')
.removeClass('fa-copy')
.addClass('fa-check text-success');
setTimeout(() => {
btn.find('i')
.removeClass('fa-check text-success')
.addClass('fa-copy');
}, 2000);
return false;
});
function handleDelete(confirmMsg, path) {
const uids = getSelectedUids();
if (!uids.length) {

View File

@@ -105,78 +105,85 @@
<table class="table users-table text-sm">
<thead>
<tr>
<th><input component="user/select/all" type="checkbox"/></th>
<th class="text-end text-muted">[[admin/manage/users:users.uid]]</th>
<th class="text-muted">[[admin/manage/users:users.username]]</th>
<th class="text-muted">[[admin/manage/users:users.email]]</th>
<th class="text-muted">[[admin/manage/users:users.ip]]</th>
<th><div><input class="form-check-input border-secondary" component="user/select/all" type="checkbox"/></div></th>
<th class="text-muted">[[admin/manage/users:users.username]] / [[admin/manage/users:users.email]]</th>
<th data-sort="postcount" class="text-end pointer text-nowrap">[[admin/manage/users:users.postcount]] {{{if sort_postcount}}}<i class="fa fa-sort-{{{if reverse}}}down{{{else}}}up{{{end}}}">{{{end}}}</th>
<th data-sort="reputation" class="text-end pointer text-nowrap">[[admin/manage/users:users.reputation]] {{{if sort_reputation}}}<i class="fa fa-sort-{{{if reverse}}}down{{{else}}}up{{{end}}}">{{{end}}}</th>
<th data-sort="flags" class="text-end pointer text-nowrap">[[admin/manage/users:users.flags]] {{{if sort_flags}}}<i class="fa fa-sort-{{{if reverse}}}down{{{else}}}up{{{end}}}">{{{end}}}</th>
<th data-sort="joindate" class="pointer text-nowrap">[[admin/manage/users:users.joined]] {{{if sort_joindate}}}<i class="fa fa-sort-{{{if reverse}}}down{{{else}}}up{{{end}}}">{{{end}}}</th>
<th data-sort="lastonline" class="pointer text-nowrap">[[admin/manage/users:users.last-online]] {{{if sort_lastonline}}}<i class="fa fa-sort-{{{if reverse}}}down{{{else}}}up{{{end}}}">{{{end}}}</th>
<th></th>
</tr>
</thead>
<tbody>
{{{ each users }}}
<tr class="user-row align-middle">
<td><input component="user/select/single" data-uid="{users.uid}" type="checkbox"/></th>
<td class="text-end text-tabular">{users.uid}</td>
<tr class="user-row align-middle hover-parent">
<td><div><input class="form-check-input border-secondary" component="user/select/single" data-uid="{users.uid}" type="checkbox"/></div></td>
<td>
<i title="[[admin/manage/users:users.banned]]" class="ban fa fa-gavel text-danger{{{ if !users.banned }}} hidden{{{ end }}}"></i>
<i class="administrator fa fa-shield text-success{{{ if !users.administrator }}} hidden{{{ end }}}"></i>
<a href="{config.relative_path}/user/{users.userslug}"> {users.username}</a>
</td>
<td class="text-nowrap">
<div class="d-flex flex-column gap-1">
<em class="text-muted no-email {{{ if (./email || ./emailToConfirm) }}}hidden{{{ end }}} ">[[admin/manage/users:users.no-email]]</em>
<div>
<i title="[[admin/manage/users:users.banned]]" class="ban fa fa-gavel text-danger{{{ if !users.banned }}} hidden{{{ end }}}"></i>
<i class="administrator fa fa-shield text-success{{{ if !users.administrator }}} hidden{{{ end }}}"></i>
<a href="{config.relative_path}/user/{users.userslug}"> {users.username}</a>
</div>
<div class="d-flex flex-column gap-1 text-truncate text-muted">
<em class="text-muted no-email {{{ if (./email || ./emailToConfirm) }}}hidden{{{ end }}} ">[[admin/manage/users:users.no-email]]</em>
<span class="validated {{{ if !users.email:confirmed }}} hidden{{{ end }}}">
<i class="fa fa-fw fa-check text-success" title="[[admin/manage/users:users.validated]]" data-bs-toggle="tooltip"></i>
<span class="email">{{{ if ./email }}}{./email}{{{ end }}}</span>
</span>
<span class="validated {{{ if !users.email:confirmed }}} hidden{{{ end }}}">
<span class="email">{{{ if ./email }}}{./email}{{{ end }}}</span>
<i class="fa fa-fw fa-check text-success" title="[[admin/manage/users:users.validated]]" data-bs-toggle="tooltip"></i>
<span class="validated-by-admin hidden">
<i class="fa fa-fw fa-check text-success" title="[[admin/manage/users:users.validated]]" data-bs-toggle="tooltip"></i>
<span class="email">{{{ if ./emailToConfirm }}}{./emailToConfirm}{{{ end }}}</span>
</span>
</span>
<span class="pending {{{ if (!./emailToConfirm || !users.email:pending) }}} hidden{{{ end }}}">
<i class="fa fa-fw fa-clock-o text-warning" title="[[admin/manage/users:users.validation-pending]]" data-bs-toggle="tooltip"></i>
<span class="email">{./emailToConfirm}</span>
</span>
<span class="validated-by-admin hidden">
<span class="email">{{{ if ./emailToConfirm }}}{./emailToConfirm}{{{ end }}}</span>
<i class="fa fa-fw fa-check text-success" title="[[admin/manage/users:users.validated]]" data-bs-toggle="tooltip"></i>
<span class="expired {{{ if (!./emailToConfirm || !users.email:expired) }}} hidden{{{ end }}}">
<i class="fa fa-fw fa-times text-danger" title="[[admin/manage/users:users.validation-expired]]" data-bs-toggle="tooltip"></i>
<span class="email">{./emailToConfirm}</span>
</span>
</span>
<span class="notvalidated {{{ if (!./emailToConfirm || (users.email:expired || (users.email:pending || users.email:confirmed))) }}} hidden{{{ end }}}">
<i class="fa fa-fw fa-times text-danger" title="[[admin/manage/users:users.not-validated]]" data-bs-toggle="tooltip"></i>
<span class="email">{./emailToConfirm}</span>
</span>
<span class="pending {{{ if (!./emailToConfirm || !users.email:pending) }}} hidden{{{ end }}}">
<span class="email">{./emailToConfirm}</span>
<i class="fa fa-fw fa-clock-o text-warning" title="[[admin/manage/users:users.validation-pending]]" data-bs-toggle="tooltip"></i>
</span>
<span class="expired {{{ if (!./emailToConfirm || !users.email:expired) }}} hidden{{{ end }}}">
<span class="email">{./emailToConfirm}</span>
<i class="fa fa-fw fa-times text-danger" title="[[admin/manage/users:users.validation-expired]]" data-bs-toggle="tooltip"></i>
</span>
<span class="notvalidated {{{ if (!./emailToConfirm || (users.email:expired || (users.email:pending || users.email:confirmed))) }}} hidden{{{ end }}}">
<span class="email">{./emailToConfirm}</span>
<i class="fa fa-fw fa-times text-danger" title="[[admin/manage/users:users.not-validated]]" data-bs-toggle="tooltip"></i>
</span>
</div>
</div>
</td>
<td class="text-end text-tabular">{formattedNumber(users.postcount)}</td>
<td class="text-end text-tabular" component="user/reputation" data-uid="{users.uid}">{formattedNumber(users.reputation)}</td>
<td class="text-end text-tabular">{{{ if users.flags }}}{users.flags}{{{ else }}}0{{{ end }}}</td>
<td class="text-nowrap"><span class="timeago" title="{users.joindateISO}"></span></td>
<td class="text-nowrap"><span class="timeago" title="{users.lastonlineISO}"></span></td>
<td>
{{{ if ./ips.length }}}
<div class="dropdown">
<div class="dropdown hover-visible">
<button class="btn btn-light btn-sm" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"><i class="fa fa-fw fa-list text-muted"></i></button>
<ul class="dropdown-menu p-1" role="menu">
<li><h6 class="dropdown-header">[[admin/manage/users:users.user-id]]</h6></li>
<li class="d-flex gap-1">
<a class="dropdown-item rounded-1" role="menuitem">{./uid}</a>
<button data-copy="{./uid}" class="btn btn-light btn-sm"><i class="fa fa-copy"></i></button>
</li>
<li class="dropdown-divider"></li>
<li><h6 class="dropdown-header">[[admin/manage/users:users.recent-ips]]</h6></li>
{{{ each ./ips }}}
<li class="d-flex gap-1 {{{ if !@last }}}mb-1{{{ end }}}">
<a class="dropdown-item rounded-1" role="menuitem">{@value}</a>
<button data-ip="{@value}" onclick="navigator.clipboard.writeText(this.getAttribute('data-ip'))" class="btn btn-light btn-sm"><i class="fa fa-copy"></i></button>
<button data-copy="{@value}" class="btn btn-light btn-sm"><i class="fa fa-copy"></i></button>
</li>
{{{ end }}}
</ul>
</div>
{{{ end }}}
</td>
<td class="text-end text-tabular">{formattedNumber(users.postcount)}</td>
<td class="text-end text-tabular" component="user/reputation" data-uid="{users.uid}">{formattedNumber(users.reputation)}</td>
<td class="text-end text-tabular">{{{ if users.flags }}}{users.flags}{{{ else }}}0{{{ end }}}</td>
<td><span class="timeago" title="{users.joindateISO}"></span></td>
<td><span class="timeago" title="{users.lastonlineISO}"></span></td>
</tr>
{{{ end }}}
</tbody>