Update email list page and tweak search fields (#641)

This commit is contained in:
Leendert de Borst
2025-03-19 22:10:13 +01:00
parent 9ea845b497
commit d6932f33ea
10 changed files with 402 additions and 107 deletions

View File

@@ -0,0 +1,5 @@
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<svg class="w-5 h-5 text-gray-500 dark:text-gray-400" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd"></path>
</svg>
</div>

View File

@@ -7,12 +7,25 @@
<PageHeader
BreadcrumbItems="@BreadcrumbItems"
Title="@(TotalRecords > 0 ? $"Emails ({TotalRecords:N0})" : "Emails")"
Description="This page shows an overview of recently received mails by this AliasVault server. Note that all email fields except 'To' are encrypted with the public key of the user and cannot be decrypted by the server.">
Description="This page shows an overview of recently received mails by this AliasVault server. Note: all email fields except 'To' are encrypted with the public key of the user and are unreadable by the server.">
<CustomActions>
<RefreshButton OnClick="RefreshData" ButtonText="Refresh" />
</CustomActions>
</PageHeader>
@if (IsInitialized)
{
<div class="px-4">
<Paginator CurrentPage="CurrentPage" PageSize="PageSize" TotalRecords="TotalRecords" OnPageChanged="HandlePageChanged" />
<div class="mb-3">
<div class="relative">
<SearchIcon />
<input type="text" @bind-value="SearchTerm" @bind-value:event="oninput" id="search" placeholder="Search emails..." class="w-full px-4 ps-10 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
</div>
</div>
</div>
}
@if (IsLoading)
{
<LoadingIndicator />
@@ -20,23 +33,25 @@
else
{
<div class="overflow-x-auto px-4">
<Paginator CurrentPage="CurrentPage" PageSize="PageSize" TotalRecords="TotalRecords" OnPageChanged="HandlePageChanged" />
<SortableTable Columns="@_tableColumns" SortColumn="@SortColumn" SortDirection="@SortDirection" OnSortChanged="HandleSortChanged">
@foreach (var email in EmailList)
@foreach (var viewModel in EmailViewModelList)
{
<SortableTableRow>
<SortableTableColumn IsPrimary="true">@email.Id</SortableTableColumn>
<SortableTableColumn>@email.DateSystem.ToString("yyyy-MM-dd HH:mm")</SortableTableColumn>
<SortableTableColumn>@(email.FromLocal.Length > 15 ? email.FromLocal.Substring(0, 15) : email.FromLocal)@@@(email.FromDomain.Length > 15 ? email.FromDomain.Substring(0, 15) : email.FromDomain)</SortableTableColumn>
<SortableTableColumn>@email.ToLocal@@@email.ToDomain</SortableTableColumn>
<SortableTableColumn>@(email.Subject.Length > 30 ? email.Subject.Substring(0, 30) : email.Subject)</SortableTableColumn>
<SortableTableColumn IsPrimary="true">@viewModel.Email.Id</SortableTableColumn>
<SortableTableColumn>@viewModel.Email.DateSystem.ToString("yyyy-MM-dd HH:mm")</SortableTableColumn>
<SortableTableColumn>@(viewModel.Email.FromLocal.Length > 10 ? viewModel.Email.FromLocal.Substring(0, 10) : viewModel.Email.FromLocal)@@@(viewModel.Email.FromDomain.Length > 10 ? viewModel.Email.FromDomain.Substring(0, 10) : viewModel.Email.FromDomain)</SortableTableColumn>
<SortableTableColumn>@viewModel.Email.ToLocal@@@viewModel.Email.ToDomain</SortableTableColumn>
<SortableTableColumn>
<span class="line-clamp-1">
@(email.MessagePreview?.Length > 30 ? email.MessagePreview.Substring(0, 30) : email.MessagePreview)
</span>
@if (viewModel.UserName.Length > 0)
{
<span class="line-clamp-1"><a href="/users/@viewModel.UserId">@viewModel.UserName</a></span>
}
else
{
<span class="line-clamp-1">n/a</span>
}
</SortableTableColumn>
<SortableTableColumn>@email.Attachments.Count</SortableTableColumn>
<SortableTableColumn>@viewModel.Email.Attachments.Count</SortableTableColumn>
</SortableTableRow>
}
</SortableTable>
@@ -49,20 +64,40 @@ else
new TableColumn { Title = "Time", PropertyName = "DateSystem" },
new TableColumn { Title = "From", PropertyName = "From" },
new TableColumn { Title = "To", PropertyName = "To" },
new TableColumn { Title = "Subject", PropertyName = "Subject" },
new TableColumn { Title = "Preview", PropertyName = "MessagePreview" },
new TableColumn { Title = "User", Sortable = false },
new TableColumn { Title = "Attachments", PropertyName = "Attachments" },
];
private List<Email> EmailList { get; set; } = [];
private List<EmailViewModel> EmailViewModelList { get; set; } = [];
private bool IsInitialized { get; set; } = false;
private bool IsLoading { get; set; } = true;
private int CurrentPage { get; set; } = 1;
private int PageSize { get; set; } = 50;
private int TotalRecords { get; set; }
private string _searchTerm = string.Empty;
/// <summary>
/// The last search term.
/// </summary>
private string _lastSearchTerm = string.Empty;
private string SearchTerm
{
get => _searchTerm;
set
{
if (_searchTerm != value)
{
_searchTerm = value;
_ = RefreshData();
}
}
}
private string SortColumn { get; set; } = "Id";
private SortDirection SortDirection { get; set; } = SortDirection.Descending;
private async Task HandleSortChanged((string column, SortDirection direction) sort)
{
SortColumn = sort.column;
@@ -91,8 +126,68 @@ else
StateHasChanged();
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
IQueryable<Email> query = dbContext.Emails;
query = ApplySearchFilter(query);
query = ApplySort(query);
TotalRecords = await query.CountAsync();
var emailList = await query
.Skip((CurrentPage - 1) * PageSize)
.Take(PageSize)
.ToListAsync();
// Get all usernames for the emails in the current list
var encryptionKeyIds = emailList.Select(x => x.UserEncryptionKeyId).Distinct().ToList();
var encryptionKeyUsernames = await dbContext.UserEncryptionKeys
.Where(x => encryptionKeyIds.Contains(x.Id))
.Join(dbContext.AliasVaultUsers, x => x.UserId, y => y.Id, (x, y) => new { EncryptionKeyId = x.Id, UserId = y.Id, y.UserName })
.ToListAsync();
// Create new list of viewmodels
EmailViewModelList = new List<EmailViewModel>();
foreach (var email in emailList)
{
var encryptionKey = encryptionKeyUsernames.FirstOrDefault(x => x.EncryptionKeyId == email.UserEncryptionKeyId);
EmailViewModelList.Add(new EmailViewModel { Email = email, UserId = encryptionKey?.UserId ?? string.Empty, UserName = encryptionKey?.UserName ?? string.Empty });
}
IsLoading = false;
IsInitialized = true;
StateHasChanged();
}
/// <summary>
/// Applies a search filter to the query based on the search term.
/// </summary>
/// <param name="query">The query to filter.</param>
/// <returns>The filtered query.</returns>
private IQueryable<Email> ApplySearchFilter(IQueryable<Email> query)
{
if (SearchTerm.Length > 0)
{
// Reset page number back to 1 if the search term has changed.
if (SearchTerm != _lastSearchTerm && CurrentPage != 1)
{
CurrentPage = 1;
}
_lastSearchTerm = SearchTerm;
query = query.Where(x => EF.Functions.Like(x.To.ToLower(), "%" + SearchTerm.Trim().ToLower() + "%"));
}
return query;
}
/// <summary>
/// Applies sorting to the query based on the sort column and direction.
/// </summary>
/// <param name="query">The query to sort.</param>
/// <returns>The sorted query.</returns>
private IQueryable<Email> ApplySort(IQueryable<Email> query)
{
// Apply sort
switch (SortColumn)
{
@@ -116,16 +211,6 @@ else
? query.OrderBy(x => x.ToLocal + "@" + x.ToDomain)
: query.OrderByDescending(x => x.ToLocal + "@" + x.ToDomain);
break;
case "Subject":
query = SortDirection == SortDirection.Ascending
? query.OrderBy(x => x.Subject)
: query.OrderByDescending(x => x.Subject);
break;
case "MessagePreview":
query = SortDirection == SortDirection.Ascending
? query.OrderBy(x => x.MessagePreview)
: query.OrderByDescending(x => x.MessagePreview);
break;
case "Attachments":
query = SortDirection == SortDirection.Ascending
? query.OrderBy(x => x.Attachments.Count)
@@ -136,13 +221,13 @@ else
break;
}
TotalRecords = await query.CountAsync();
EmailList = await query
.Skip((CurrentPage - 1) * PageSize)
.Take(PageSize)
.ToListAsync();
return query;
}
IsLoading = false;
StateHasChanged();
private sealed class EmailViewModel
{
public Email Email { get; set; } = new();
public string UserId { get; set; } = string.Empty;
public string UserName { get; set; } = string.Empty;
}
}

View File

@@ -15,19 +15,18 @@
</CustomActions>
</PageHeader>
@if (IsLoading)
{
<LoadingIndicator />
}
else
@if (IsInitialized)
{
<div class="px-4">
<Paginator CurrentPage="CurrentPage" PageSize="PageSize" TotalRecords="TotalRecords" OnPageChanged="HandlePageChanged" />
<div class="mb-4 flex space-x-4">
<div class="mb-3 flex space-x-4">
<div class="flex w-full">
<div class="w-2/3 pr-2">
<input type="text" @bind-value="SearchTerm" @bind-value:event="oninput" id="search" placeholder="Search logs..." class="w-full px-4 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
<div class="relative">
<SearchIcon />
<input type="text" @bind-value="SearchTerm" @bind-value:event="oninput" id="search" placeholder="Search logs..." class="w-full px-4 ps-10 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
</div>
</div>
<div class="w-1/3 pl-2">
<select @bind="SelectedEventType" class="w-full px-4 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
@@ -40,7 +39,16 @@ else
</div>
</div>
</div>
</div>
}
@if (IsLoading)
{
<LoadingIndicator />
}
else
{
<div class="px-4">
<SortableTable Columns="@_tableColumns" SortColumn="@SortColumn" SortDirection="@SortDirection" OnSortChanged="HandleSortChanged">
@foreach (var log in LogList)
{
@@ -70,6 +78,7 @@ else
];
private List<AuthLog> LogList { get; set; } = [];
private bool IsInitialized { get; set; } = false;
private bool IsLoading { get; set; } = true;
private int CurrentPage { get; set; } = 1;
private int PageSize { get; set; } = 50;
@@ -131,6 +140,9 @@ else
private async Task RefreshData()
{
IsLoading = true;
StateHasChanged();
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
var query = dbContext.AuthLogs.AsQueryable();
@@ -151,8 +163,9 @@ else
}
else
{
query = query.Where(x => EF.Functions.Like((x.Username ?? string.Empty).ToLower(), "%" + SearchTerm.ToLower() + "%") ||
EF.Functions.Like((x.IpAddress ?? string.Empty).ToLower(), "%" + SearchTerm.ToLower() + "%"));
var searchTerm = SearchTerm.Trim().ToLower();
query = query.Where(x => EF.Functions.Like((x.Username ?? string.Empty).ToLower(), "%" + searchTerm + "%") ||
EF.Functions.Like((x.IpAddress ?? string.Empty).ToLower(), "%" + searchTerm + "%"));
}
}
@@ -174,6 +187,7 @@ else
.ToListAsync();
IsLoading = false;
IsInitialized = true;
StateHasChanged();
}

View File

@@ -14,19 +14,18 @@
</CustomActions>
</PageHeader>
@if (IsLoading)
{
<LoadingIndicator />
}
else
@if (IsInitialized)
{
<div class="px-4">
<Paginator CurrentPage="CurrentPage" PageSize="PageSize" TotalRecords="TotalRecords" OnPageChanged="HandlePageChanged" />
<div class="mb-4 flex space-x-4">
<div class="mb-3 flex space-x-4">
<div class="flex w-full">
<div class="w-2/3 pr-2">
<input type="text" @bind-value="SearchTerm" @bind-value:event="oninput" id="search" placeholder="Search logs..." class="w-full px-4 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
<div class="relative">
<SearchIcon />
<input type="text" @bind-value="SearchTerm" @bind-value:event="oninput" id="search" placeholder="Search logs..." class="w-full px-4 ps-10 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
</div>
</div>
<div class="w-1/3 pl-2">
<select @bind="SelectedServiceName" class="w-full px-4 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
@@ -39,7 +38,16 @@ else
</div>
</div>
</div>
</div>
}
@if (IsLoading)
{
<LoadingIndicator />
}
else
{
<div class="px-4">
<SortableTable Columns="@_tableColumns" SortColumn="@SortColumn" SortDirection="@SortDirection" OnSortChanged="HandleSortChanged">
@foreach (var log in LogList)
{
@@ -85,6 +93,8 @@ else
];
private List<Log> LogList { get; set; } = [];
private bool IsInitialized { get; set; } = false;
private bool IsLoading { get; set; } = true;
private int CurrentPage { get; set; } = 1;
private int PageSize { get; set; } = 50;
@@ -150,6 +160,9 @@ else
private async Task RefreshData()
{
IsLoading = true;
StateHasChanged();
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
var query = dbContext.Logs.AsQueryable();
@@ -193,6 +206,7 @@ else
.ToListAsync();
IsLoading = false;
IsInitialized = true;
StateHasChanged();
}
@@ -211,10 +225,11 @@ else
}
_lastSearchTerm = SearchTerm;
query = query.Where(x => EF.Functions.Like(x.Application.ToLower(), "%" + SearchTerm.ToLower() + "%") ||
EF.Functions.Like(x.Message.ToLower(), "%" + SearchTerm.ToLower() + "%") ||
EF.Functions.Like(x.Level.ToLower(), "%" + SearchTerm.ToLower() + "%") ||
EF.Functions.Like(x.SourceContext.ToLower(), "%" + SearchTerm.ToLower() + "%"));
var searchTerm = SearchTerm.Trim().ToLower();
query = query.Where(x => EF.Functions.Like(x.Application.ToLower(), "%" + searchTerm + "%") ||
EF.Functions.Like(x.Message.ToLower(), "%" + searchTerm + "%") ||
EF.Functions.Like(x.Level.ToLower(), "%" + searchTerm + "%") ||
EF.Functions.Like(x.SourceContext.ToLower(), "%" + searchTerm + "%"));
}
return query;

View File

@@ -13,6 +13,19 @@
</CustomActions>
</PageHeader>
@if (IsInitialized)
{
<div class="px-4">
<Paginator CurrentPage="CurrentPage" PageSize="PageSize" TotalRecords="TotalRecords" OnPageChanged="HandlePageChanged" />
<div class="mb-3">
<div class="relative">
<SearchIcon />
<input type="text" @bind-value="SearchTerm" @bind-value:event="oninput" id="search" placeholder="Search users..." class="w-full px-4 ps-10 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
</div>
</div>
</div>
}
@if (IsLoading)
{
<LoadingIndicator />
@@ -20,12 +33,6 @@
else
{
<div class="px-4">
<Paginator CurrentPage="CurrentPage" PageSize="PageSize" TotalRecords="TotalRecords" OnPageChanged="HandlePageChanged" />
<div class="mb-4">
<input type="text" @bind-value="SearchTerm" @bind-value:event="oninput" id="search" placeholder="Search users..." class="w-full px-4 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
</div>
<SortableTable Columns="@_tableColumns" SortColumn="@SortColumn" SortDirection="@SortDirection" OnSortChanged="HandleSortChanged">
@foreach (var user in UserList)
{
@@ -70,6 +77,7 @@ else
];
private List<UserViewModel> UserList { get; set; } = [];
private bool IsInitialized { get; set; } = false;
private bool IsLoading { get; set; } = true;
private int CurrentPage { get; set; } = 1;
private int PageSize { get; set; } = 50;
@@ -122,22 +130,13 @@ else
private async Task RefreshData()
{
IsLoading = true;
StateHasChanged();
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
IQueryable<AliasVaultUser> query = dbContext.AliasVaultUsers;
if (SearchTerm.Length > 0)
{
// Reset page number back to 1 if the search term has changed.
if (SearchTerm != _lastSearchTerm && CurrentPage != 1)
{
CurrentPage = 1;
}
_lastSearchTerm = SearchTerm;
query = query.Where(x => EF.Functions.Like(x.UserName!.ToLower(), "%" + SearchTerm.ToLower() + "%"));
}
// Apply sort.
query = ApplySearchFilter(query);
query = ApplySort(query);
TotalRecords = await query.CountAsync();
@@ -180,9 +179,31 @@ else
}).ToList();
IsLoading = false;
IsInitialized = true;
StateHasChanged();
}
/// <summary>
/// Apply search filter to the query.
/// </summary>
private IQueryable<AliasVaultUser> ApplySearchFilter(IQueryable<AliasVaultUser> query)
{
if (SearchTerm.Length > 0)
{
// Reset page number back to 1 if the search term has changed.
if (SearchTerm != _lastSearchTerm && CurrentPage != 1)
{
CurrentPage = 1;
}
_lastSearchTerm = SearchTerm;
var searchTerm = SearchTerm.Trim().ToLower();
query = query.Where(x => EF.Functions.Like(x.UserName!.ToLower(), "%" + searchTerm + "%"));
}
return query;
}
/// <summary>
/// Apply sort to the query.
/// </summary>

View File

@@ -25,39 +25,57 @@ else
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<div class="items-center xl:block sm:space-x-4 xl:space-x-0 2xl:space-x-4">
<div>
<h3 class="mb-1 text-xl font-bold text-gray-900 dark:text-white">@User.UserName</h3>
<h3 class="mb-4 text-2xl font-bold text-gray-900 dark:text-white border-b border-gray-200 pb-2">@User.UserName</h3>
<div class="mb-4">
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Id</label>
<div class="text-gray-700 dark:text-gray-300">@User.Id</div>
</div>
<div class="flex items-center space-x-2">
<span class="text-sm font-medium text-gray-900 dark:text-white">2FA Status:</span>
<StatusPill Enabled="@User.TwoFactorEnabled"/>
<span class="text-gray-700 dark:text-gray-300">Authenticator key(s) active: @TwoFactorKeysCount</span>
@if (User.TwoFactorEnabled)
{
<Button Color="danger" OnClick="DisableTwoFactor">Disable 2FA</Button>
}
else
{
if (TwoFactorKeysCount > 0)
{
<Button Color="success" OnClick="EnableTwoFactor">Enable 2FA</Button>
<Button Color="danger" OnClick="ResetTwoFactor">Remove 2FA keys</Button>
}
}
</div>
<div class="flex items-center space-x-2 mt-4">
<span class="text-sm font-medium text-gray-900 dark:text-white">Account Status:</span>
<StatusPill Enabled="@(!User.Blocked)" TextTrue="Active" TextFalse="Blocked" />
<Button Color="@(User.Blocked ? "success" : "danger")" OnClick="ToggleBlockStatus">
@(User.Blocked ? "Unblock User" : "Block User")
</Button>
<span class="text-sm text-gray-500 dark:text-gray-400">
<i class="fas fa-info-circle mr-1"></i>
Blocking a user prevents them from logging in or accessing AliasVault
</span>
<div class="w-full mb-4 overflow-x-auto">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
<tbody>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="px-4 py-3 font-medium text-gray-900 whitespace-nowrap dark:text-white">Id</th>
<td class="px-4 py-3">@User.Id</td>
</tr>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="px-4 py-3 font-medium text-gray-900 whitespace-nowrap dark:text-white">Registered at</th>
<td class="px-4 py-3">@User.CreatedAt.ToString("yyyy-MM-dd HH:mm")</td>
</tr>
<tr>
<th scope="row" class="px-4 py-3 font-medium text-gray-900 whitespace-nowrap dark:text-white">2FA Status</th>
<td class="px-4 py-3">
<div class="flex items-center space-x-2">
<StatusPill Enabled="@User.TwoFactorEnabled"/>
<span class="text-gray-700 dark:text-gray-300">Authenticator key(s) active: @TwoFactorKeysCount</span>
@if (User.TwoFactorEnabled)
{
<Button Color="danger" OnClick="DisableTwoFactor">Disable 2FA</Button>
}
else
{
if (TwoFactorKeysCount > 0)
{
<Button Color="success" OnClick="EnableTwoFactor">Enable 2FA</Button>
<Button Color="danger" OnClick="ResetTwoFactor">Remove 2FA keys</Button>
}
}
</div>
</td>
</tr>
<tr>
<th scope="row" class="px-4 py-3 font-medium text-gray-900 whitespace-nowrap dark:text-white">Account Status</th>
<td class="px-4 py-3">
<div class="flex items-center space-x-2">
<StatusPill Enabled="@(!User.Blocked)" TextTrue="Active" TextFalse="Blocked" />
<Button Color="@(User.Blocked ? "success" : "danger")" OnClick="ToggleBlockStatus">
@(User.Blocked ? "Unblock User" : "Block User")
</Button>
<span class="text-sm text-gray-500 dark:text-gray-400">
<i class="fas fa-info-circle mr-1"></i>
Blocking a user prevents them from logging in or accessing AliasVault
</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>

View File

@@ -15,6 +15,7 @@
@using AliasVault.Admin.Main.Layout
@using AliasVault.Admin.Main.Components
@using AliasVault.Admin.Main.Components.Alerts
@using AliasVault.Admin.Main.Components.Icons
@using AliasVault.Admin.Main.Components.Layout
@using AliasVault.Admin.Main.Components.Loading
@using AliasVault.Admin.Main.Components.WorkerStatus

View File

@@ -566,6 +566,10 @@ video {
border-width: 0;
}
.pointer-events-none {
pointer-events: none;
}
.visible {
visibility: visible;
}
@@ -594,6 +598,11 @@ video {
inset: 0px;
}
.inset-y-0 {
top: 0px;
bottom: 0px;
}
.right-0 {
right: 0px;
}
@@ -602,6 +611,10 @@ video {
top: 38px;
}
.left-0 {
left: 0px;
}
.z-10 {
z-index: 10;
}
@@ -730,6 +743,18 @@ video {
margin-top: 2rem;
}
.ms-4 {
margin-inline-start: 1rem;
}
.mb-3 {
margin-bottom: 0.75rem;
}
.mb-5 {
margin-bottom: 1.25rem;
}
.line-clamp-1 {
overflow: hidden;
display: -webkit-box;
@@ -889,6 +914,10 @@ video {
max-width: 36rem;
}
.flex-1 {
flex: 1 1 0%;
}
.flex-shrink-0 {
flex-shrink: 0;
}
@@ -939,6 +968,10 @@ video {
grid-template-columns: repeat(7, minmax(0, 1fr));
}
.grid-cols-\[150px_1fr\] {
grid-template-columns: 150px 1fr;
}
.flex-col {
flex-direction: column;
}
@@ -951,6 +984,10 @@ video {
align-items: flex-start;
}
.items-end {
align-items: flex-end;
}
.items-center {
align-items: center;
}
@@ -979,6 +1016,14 @@ video {
gap: 2rem;
}
.gap-2 {
gap: 0.5rem;
}
.gap-3 {
gap: 0.75rem;
}
.space-x-1 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(0.25rem * var(--tw-space-x-reverse));
@@ -1320,6 +1365,11 @@ video {
background-color: rgb(234 179 8 / var(--tw-bg-opacity));
}
.bg-blue-700 {
--tw-bg-opacity: 1;
background-color: rgb(29 78 216 / var(--tw-bg-opacity));
}
.bg-opacity-50 {
--tw-bg-opacity: 0.5;
}
@@ -1457,6 +1507,30 @@ video {
padding-top: 2rem;
}
.pb-2 {
padding-bottom: 0.5rem;
}
.pl-10 {
padding-left: 2.5rem;
}
.pl-3 {
padding-left: 0.75rem;
}
.ps-8 {
padding-inline-start: 2rem;
}
.ps-12 {
padding-inline-start: 3rem;
}
.ps-10 {
padding-inline-start: 2.5rem;
}
.text-left {
text-align: left;
}
@@ -1733,6 +1807,11 @@ video {
background-color: rgb(153 27 27 / var(--tw-bg-opacity));
}
.hover\:bg-blue-800:hover {
--tw-bg-opacity: 1;
background-color: rgb(30 64 175 / var(--tw-bg-opacity));
}
.hover\:text-gray-700:hover {
--tw-text-opacity: 1;
color: rgb(55 65 81 / var(--tw-text-opacity));
@@ -1829,6 +1908,11 @@ video {
--tw-ring-color: rgb(252 165 165 / var(--tw-ring-opacity));
}
.focus\:ring-blue-300:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(147 197 253 / var(--tw-ring-opacity));
}
.dark\:divide-gray-600:is(.dark *) > :not([hidden]) ~ :not([hidden]) {
--tw-divide-opacity: 1;
border-color: rgb(75 85 99 / var(--tw-divide-opacity));
@@ -1969,6 +2053,11 @@ video {
background-color: rgb(113 63 18 / var(--tw-bg-opacity));
}
.dark\:bg-blue-600:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(37 99 235 / var(--tw-bg-opacity));
}
.dark\:bg-opacity-80:is(.dark *) {
--tw-bg-opacity: 0.8;
}
@@ -2053,6 +2142,11 @@ video {
color: rgb(254 240 138 / var(--tw-text-opacity));
}
.dark\:text-blue-500:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(59 130 246 / var(--tw-text-opacity));
}
.dark\:placeholder-gray-400:is(.dark *)::-moz-placeholder {
--tw-placeholder-opacity: 1;
color: rgb(156 163 175 / var(--tw-placeholder-opacity));
@@ -2092,6 +2186,11 @@ video {
background-color: rgb(185 28 28 / var(--tw-bg-opacity));
}
.dark\:hover\:bg-blue-700:hover:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(29 78 216 / var(--tw-bg-opacity));
}
.dark\:hover\:text-gray-300:hover:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(209 213 219 / var(--tw-text-opacity));
@@ -2167,6 +2266,11 @@ video {
--tw-ring-color: rgb(153 27 27 / var(--tw-ring-opacity));
}
.dark\:focus\:ring-blue-800:focus:is(.dark *) {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(30 64 175 / var(--tw-ring-opacity));
}
@media (min-width: 640px) {
.sm\:mb-5 {
margin-bottom: 1.25rem;

View File

@@ -93,3 +93,35 @@ function generateQrCode(id) {
qrcode.makeCode(dataUrl);
}
// Keyboard navigation for pagination
window.enablePaginationKeyboardNavigation = (element, dotNetHelper, currentPage, maxPage) => {
if (!element) return;
// Add tabindex and focus if not already set
if (!element.hasAttribute('tabindex')) {
element.setAttribute('tabindex', '0');
}
// Remove any existing event listener to prevent duplicates
if (element._paginationKeyHandler) {
element.removeEventListener('keydown', element._paginationKeyHandler);
}
// Create keyboard event handler
element._paginationKeyHandler = (e) => {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
const newPage = e.key === 'ArrowLeft'
? Math.max(1, currentPage - 1)
: Math.min(maxPage, currentPage + 1);
if (newPage !== currentPage) {
dotNetHelper.invokeMethodAsync('NavigateToPage', newPage);
}
}
};
// Add event listener
element.addEventListener('keydown', element._paginationKeyHandler);
};

View File

@@ -1,7 +1,7 @@
@if (TotalRecords > PageSize)
{
<!-- Navigation component for pagination -->
<nav aria-label="Page navigation" class="mt-4 flex justify-end mb-4">
<nav aria-label="Page navigation" class="mt-4 flex justify-end mb-5">
<ul class="flex space-x-2">
<!-- First page button -->
<li class="@(CurrentPage == 1 ? "opacity-50 cursor-not-allowed" : "")">
@@ -14,7 +14,7 @@
@for (var i = 1; i <= PageCount; i++)
{
var pageNum = i;
if (i > 5 && i < PageCount - 5 && Math.Abs(CurrentPage - i) > 2)
if (i > 2 && i < PageCount - 2 && Math.Abs(CurrentPage - i) > 3)
{
// Don't render intermediate pages when there are many pages
continue;