mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-12 17:38:32 -04:00
Add email storage page to admin (#1041)
This commit is contained in:
committed by
Leendert de Borst
parent
7844f411ef
commit
4da10bbfba
@@ -41,4 +41,19 @@ public class UserUsageStatistics
|
||||
/// Gets or sets the number of emails received in the last 72 hours.
|
||||
/// </summary>
|
||||
public int RecentReceivedEmails72h { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the total number of email attachments (all-time).
|
||||
/// </summary>
|
||||
public int TotalEmailAttachments { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the total storage size of email attachments in bytes (all-time).
|
||||
/// </summary>
|
||||
public long TotalEmailAttachmentStorage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total storage size of email attachments in MB for display purposes.
|
||||
/// </summary>
|
||||
public double TotalEmailAttachmentStorageMB => TotalEmailAttachmentStorage / (1024.0 * 1024.0);
|
||||
}
|
||||
|
||||
426
apps/server/AliasVault.Admin/Main/Pages/EmailStorageStats.razor
Normal file
426
apps/server/AliasVault.Admin/Main/Pages/EmailStorageStats.razor
Normal file
@@ -0,0 +1,426 @@
|
||||
@page "/email-storage-stats"
|
||||
@inherits MainBase
|
||||
@using AliasVault.Admin.Services
|
||||
@using AliasVault.RazorComponents
|
||||
@using AliasVault.RazorComponents.Tables
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@inject StatisticsService StatisticsService
|
||||
@inject IAliasServerDbContextFactory ContextFactory
|
||||
@implements IDisposable
|
||||
|
||||
<PageHeader Title="Email Storage Stats" BreadcrumbItems="BreadcrumbItems">
|
||||
<CustomActions>
|
||||
<RefreshButton OnClick="RefreshData" ButtonText="Refresh" />
|
||||
</CustomActions>
|
||||
</PageHeader>
|
||||
|
||||
<div class="px-4 space-y-6">
|
||||
<!-- Overview Statistics Cards -->
|
||||
@if (LoadingStats)
|
||||
{
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
@for (int i = 0; i < 3; i++)
|
||||
{
|
||||
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<div class="flex items-center">
|
||||
<div class="ml-4">
|
||||
<div class="w-32 h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse mb-2"></div>
|
||||
<div class="w-16 h-6 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<div class="flex items-center">
|
||||
<div class="ml-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Total Emails</h3>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">@TotalEmails.ToString("N0")</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<div class="flex items-center">
|
||||
<div class="ml-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Email Attachments</h3>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">@TotalAttachments.ToString("N0")</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<div class="flex items-center">
|
||||
<div class="ml-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Storage Used</h3>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">@TotalStorageMB.ToString("N2") MB</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Email Attachments Table -->
|
||||
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Email Attachments</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">All email attachments stored in the system. The content of the attachments is encrypted with the user's public key and are unreadable by the server.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (LoadingAttachments)
|
||||
{
|
||||
<div class="p-4">
|
||||
<div class="animate-pulse">
|
||||
@for (int i = 0; i < 5; i++)
|
||||
{
|
||||
<div class="flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-8 h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||
<div class="w-32 h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||
<div class="w-16 h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||
<div class="w-24 h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||
</div>
|
||||
<div class="w-16 h-8 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (Attachments?.Any() == true)
|
||||
{
|
||||
<div class="overflow-x-auto">
|
||||
<SortableTable Columns="@_tableColumns" SortColumn="@SortColumn" SortDirection="@SortDirection" OnSortChanged="HandleSortChanged">
|
||||
@foreach (var attachment in Attachments)
|
||||
{
|
||||
<SortableTableRow>
|
||||
<SortableTableColumn IsPrimary="true">@attachment.Id</SortableTableColumn>
|
||||
<SortableTableColumn>
|
||||
<span class="font-medium text-gray-900 dark:text-white">@attachment.Filename</span>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">@attachment.MimeType</div>
|
||||
</SortableTableColumn>
|
||||
<SortableTableColumn>@FormatFileSize(attachment.Filesize)</SortableTableColumn>
|
||||
<SortableTableColumn>
|
||||
<span class="text-blue-600 dark:text-blue-400">@attachment.EmailId</span>
|
||||
</SortableTableColumn>
|
||||
<SortableTableColumn>
|
||||
@if (!string.IsNullOrEmpty(attachment.UserEmail))
|
||||
{
|
||||
<a href="/users/@attachment.UserId" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300">
|
||||
@attachment.UserEmail
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-gray-400 dark:text-gray-500">Unknown</span>
|
||||
}
|
||||
</SortableTableColumn>
|
||||
<SortableTableColumn>
|
||||
<button @onclick="() => DeleteAttachment(attachment.Id)"
|
||||
class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 text-sm font-medium">
|
||||
Delete
|
||||
</button>
|
||||
</SortableTableColumn>
|
||||
</SortableTableRow>
|
||||
}
|
||||
</SortableTable>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<Paginator CurrentPage="CurrentPage"
|
||||
PageSize="20"
|
||||
TotalRecords="TotalRecords"
|
||||
OnPageChanged="HandlePageChanged" />
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="p-8 text-center">
|
||||
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-gray-100 dark:bg-gray-700">
|
||||
<svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">No attachments found</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">No email attachments exist in the system.</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (ErrorMessage != null)
|
||||
{
|
||||
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-800 dark:text-red-200">Error Loading Data</h3>
|
||||
<p class="text-sm text-red-700 dark:text-red-300 mt-1">@ErrorMessage</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private bool LoadingStats = true;
|
||||
private bool LoadingAttachments = true;
|
||||
private string? ErrorMessage;
|
||||
private bool _disposed = false;
|
||||
|
||||
private int TotalEmails = 0;
|
||||
private int TotalAttachments = 0;
|
||||
private double TotalStorageMB = 0;
|
||||
|
||||
private List<EmailAttachmentModel>? Attachments;
|
||||
private int CurrentPage = 1;
|
||||
private int TotalRecords = 0;
|
||||
|
||||
private string SortColumn { get; set; } = "Filesize";
|
||||
private SortDirection SortDirection { get; set; } = SortDirection.Descending;
|
||||
|
||||
private readonly List<TableColumn> _tableColumns = [
|
||||
new TableColumn { Title = "ID", PropertyName = "Id" },
|
||||
new TableColumn { Title = "Filename", PropertyName = "Filename" },
|
||||
new TableColumn { Title = "File Size", PropertyName = "Filesize" },
|
||||
new TableColumn { Title = "Email ID", PropertyName = "EmailId" },
|
||||
new TableColumn { Title = "User", PropertyName = "UserEmail" },
|
||||
new TableColumn { Title = "Actions", Sortable = false },
|
||||
];
|
||||
|
||||
public class EmailAttachmentModel
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Filename { get; set; } = string.Empty;
|
||||
public string MimeType { get; set; } = string.Empty;
|
||||
public int Filesize { get; set; }
|
||||
public int EmailId { get; set; }
|
||||
public string? UserId { get; set; }
|
||||
public string? UserEmail { get; set; }
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "Emails", Url = "/emails" });
|
||||
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "Email Storage Stats" });
|
||||
|
||||
await RefreshData();
|
||||
}
|
||||
|
||||
private void SafeStateHasChanged()
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshData()
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
LoadingStats = true;
|
||||
LoadingAttachments = true;
|
||||
ErrorMessage = null;
|
||||
SafeStateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
await using var context = await ContextFactory.CreateDbContextAsync();
|
||||
|
||||
// Load statistics
|
||||
TotalEmails = await context.Emails.CountAsync();
|
||||
TotalAttachments = await context.EmailAttachments.CountAsync();
|
||||
|
||||
var totalBytes = TotalAttachments > 0
|
||||
? await context.EmailAttachments.SumAsync(a => (long)a.Filesize)
|
||||
: 0L;
|
||||
TotalStorageMB = totalBytes / (1024.0 * 1024.0);
|
||||
|
||||
LoadingStats = false;
|
||||
SafeStateHasChanged();
|
||||
|
||||
// Load attachments
|
||||
await LoadAttachments();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
ErrorMessage = $"Error loading data: {ex.Message}";
|
||||
LoadingStats = false;
|
||||
LoadingAttachments = false;
|
||||
SafeStateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadAttachments()
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
LoadingAttachments = true;
|
||||
SafeStateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
await using var context = await ContextFactory.CreateDbContextAsync();
|
||||
|
||||
TotalRecords = await context.EmailAttachments.CountAsync();
|
||||
|
||||
var skip = (CurrentPage - 1) * 20;
|
||||
|
||||
// Apply sorting and pagination
|
||||
var sortedQuery = ApplySort(context.EmailAttachments);
|
||||
|
||||
var attachmentData = await sortedQuery
|
||||
.Skip(skip)
|
||||
.Take(20)
|
||||
.ToListAsync();
|
||||
|
||||
// Then map to view models to avoid disposed context issues
|
||||
if (!_disposed)
|
||||
{
|
||||
Attachments = attachmentData.Select(a => new EmailAttachmentModel
|
||||
{
|
||||
Id = a.Id,
|
||||
Filename = a.Filename,
|
||||
MimeType = a.MimeType,
|
||||
Filesize = a.Filesize,
|
||||
EmailId = a.EmailId,
|
||||
UserId = a.Email?.EncryptionKey?.UserId,
|
||||
UserEmail = a.Email?.EncryptionKey?.User?.Email
|
||||
}).ToList();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
ErrorMessage = $"Error loading attachments: {ex.Message}";
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
LoadingAttachments = false;
|
||||
SafeStateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandlePageChanged(int newPage)
|
||||
{
|
||||
CurrentPage = newPage;
|
||||
await LoadAttachments();
|
||||
}
|
||||
|
||||
private async Task HandleSortChanged((string column, SortDirection direction) sort)
|
||||
{
|
||||
SortColumn = sort.column;
|
||||
SortDirection = sort.direction;
|
||||
CurrentPage = 1; // Reset to first page when sorting changes
|
||||
await LoadAttachments();
|
||||
}
|
||||
|
||||
private IQueryable<EmailAttachment> ApplySort(IQueryable<EmailAttachment> query)
|
||||
{
|
||||
// Always include navigation properties for all sorting scenarios
|
||||
query = query.Include(a => a.Email)
|
||||
.ThenInclude(e => e.EncryptionKey)
|
||||
.ThenInclude(k => k.User);
|
||||
|
||||
switch (SortColumn)
|
||||
{
|
||||
case "Id":
|
||||
query = SortDirection == SortDirection.Ascending
|
||||
? query.OrderBy(x => x.Id)
|
||||
: query.OrderByDescending(x => x.Id);
|
||||
break;
|
||||
case "Filename":
|
||||
query = SortDirection == SortDirection.Ascending
|
||||
? query.OrderBy(x => x.Filename)
|
||||
: query.OrderByDescending(x => x.Filename);
|
||||
break;
|
||||
case "Filesize":
|
||||
query = SortDirection == SortDirection.Ascending
|
||||
? query.OrderBy(x => x.Filesize)
|
||||
: query.OrderByDescending(x => x.Filesize);
|
||||
break;
|
||||
case "EmailId":
|
||||
query = SortDirection == SortDirection.Ascending
|
||||
? query.OrderBy(x => x.EmailId)
|
||||
: query.OrderByDescending(x => x.EmailId);
|
||||
break;
|
||||
case "UserEmail":
|
||||
query = SortDirection == SortDirection.Ascending
|
||||
? query.OrderBy(x => x.Email.EncryptionKey.User.Email)
|
||||
: query.OrderByDescending(x => x.Email.EncryptionKey.User.Email);
|
||||
break;
|
||||
default:
|
||||
query = query.OrderByDescending(x => x.Filesize);
|
||||
break;
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
private async Task DeleteAttachment(int attachmentId)
|
||||
{
|
||||
var confirmed = await ConfirmModalService.ShowConfirmation(
|
||||
"Delete Attachment",
|
||||
"Are you sure you want to delete this attachment? This action cannot be undone.",
|
||||
"Delete",
|
||||
"Cancel");
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
try
|
||||
{
|
||||
await using var context = await ContextFactory.CreateDbContextAsync();
|
||||
|
||||
var attachment = await context.EmailAttachments.FindAsync(attachmentId);
|
||||
if (attachment != null)
|
||||
{
|
||||
context.EmailAttachments.Remove(attachment);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
// Refresh data after deletion
|
||||
await RefreshData();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
ErrorMessage = $"Error deleting attachment: {ex.Message}";
|
||||
SafeStateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatFileSize(int bytes)
|
||||
{
|
||||
if (bytes < 1024)
|
||||
return $"{bytes} B";
|
||||
else if (bytes < 1024 * 1024)
|
||||
return $"{bytes / 1024.0:F1} KB";
|
||||
else
|
||||
return $"{bytes / (1024.0 * 1024.0):F1} MB";
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,14 @@
|
||||
<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: all email fields except 'To' are encrypted with the public key of the user and are unreadable by the server.">
|
||||
Description="This page shows all received mails by this AliasVault server. All email fields except 'To' are encrypted with the public key of the user and are unreadable by the server.">
|
||||
<CustomActions>
|
||||
<a href="email-storage-stats" class="inline-flex items-center px-3 py-2 text-sm font-medium text-gray-600 bg-gray-100 border border-gray-300 rounded-md hover:bg-gray-200 hover:text-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600 dark:hover:text-gray-200 mr-3">
|
||||
<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z"/>
|
||||
</svg>
|
||||
Email Storage Stats
|
||||
</a>
|
||||
<RefreshButton OnClick="RefreshData" ButtonText="Refresh" />
|
||||
</CustomActions>
|
||||
</PageHeader>
|
||||
|
||||
@@ -35,7 +35,7 @@ else
|
||||
{
|
||||
<div class="mb-6 bg-gray-100 dark:bg-gray-700 rounded-lg p-4">
|
||||
<h4 class="text-lg font-semibold text-gray-900 dark:text-white text-center mb-3">Usage Statistics</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div class="text-left">
|
||||
<div class="text-xl text-center font-semibold text-gray-900 dark:text-white">@UserUsageStats.TotalCredentials.ToString("N0")</div>
|
||||
<div class="text-sm text-center text-gray-600 dark:text-gray-400">Total Credentials</div>
|
||||
@@ -60,6 +60,14 @@ else
|
||||
<div class="text-xs text-center text-green-500 dark:text-green-400">(+@UserUsageStats.RecentReceivedEmails72h.ToString("N0") in last 72h)</div>
|
||||
}
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<div class="text-xl text-center font-semibold text-gray-900 dark:text-white">@UserUsageStats.TotalEmailAttachments.ToString("N0")</div>
|
||||
<div class="text-sm text-center text-gray-600 dark:text-gray-400">Email Attachments</div>
|
||||
@if (UserUsageStats.TotalEmailAttachmentStorageMB > 0)
|
||||
{
|
||||
<div class="text-xs text-center text-blue-500 dark:text-blue-400">@UserUsageStats.TotalEmailAttachmentStorageMB.ToString("N2") MB</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -167,6 +167,15 @@ public class StatisticsService
|
||||
.Where(e => e.EncryptionKey.UserId == userId && e.DateSystem >= cutoffDate)
|
||||
.CountAsync();
|
||||
|
||||
// Get email attachment statistics (all-time)
|
||||
var emailAttachmentQuery = context.EmailAttachments
|
||||
.Where(a => a.Email.EncryptionKey.UserId == userId);
|
||||
|
||||
stats.TotalEmailAttachments = await emailAttachmentQuery.CountAsync();
|
||||
stats.TotalEmailAttachmentStorage = stats.TotalEmailAttachments > 0
|
||||
? await emailAttachmentQuery.SumAsync(a => (long)a.Filesize)
|
||||
: 0L;
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
|
||||
@@ -759,6 +759,10 @@ video {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.mb-8 {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.line-clamp-1 {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
@@ -918,6 +922,14 @@ video {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.w-24 {
|
||||
width: 6rem;
|
||||
}
|
||||
|
||||
.w-32 {
|
||||
width: 8rem;
|
||||
}
|
||||
|
||||
.max-w-2xl {
|
||||
max-width: 42rem;
|
||||
}
|
||||
@@ -1187,6 +1199,10 @@ video {
|
||||
border-left-width: 4px;
|
||||
}
|
||||
|
||||
.border-t {
|
||||
border-top-width: 1px;
|
||||
}
|
||||
|
||||
.border-gray-200 {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(229 231 235 / var(--tw-border-opacity));
|
||||
@@ -1402,6 +1418,11 @@ video {
|
||||
background-color: rgb(234 179 8 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-purple-100 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(243 232 255 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-opacity-50 {
|
||||
--tw-bg-opacity: 0.5;
|
||||
}
|
||||
@@ -1430,6 +1451,10 @@ video {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.p-8 {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.px-1 {
|
||||
padding-left: 0.25rem;
|
||||
padding-right: 0.25rem;
|
||||
@@ -1637,6 +1662,10 @@ video {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.uppercase {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
@@ -1764,6 +1793,11 @@ video {
|
||||
color: rgb(34 197 94 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-blue-500 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(59 130 246 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.underline {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
@@ -1923,6 +1957,16 @@ video {
|
||||
color: rgb(154 93 38 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.hover\:text-blue-800:hover {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(30 64 175 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.hover\:text-red-900:hover {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(127 29 29 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.hover\:underline:hover {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
@@ -2152,6 +2196,15 @@ video {
|
||||
background-color: rgb(17 24 39 / 0.5);
|
||||
}
|
||||
|
||||
.dark\:bg-purple-900:is(.dark *) {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(88 28 135 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.dark\:bg-red-800\/10:is(.dark *) {
|
||||
background-color: rgb(153 27 27 / 0.1);
|
||||
}
|
||||
|
||||
.dark\:bg-opacity-80:is(.dark *) {
|
||||
--tw-bg-opacity: 0.8;
|
||||
}
|
||||
@@ -2256,6 +2309,16 @@ video {
|
||||
color: rgb(254 240 138 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.dark\:text-gray-500:is(.dark *) {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(107 114 128 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.dark\:text-purple-300:is(.dark *) {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(216 180 254 / 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));
|
||||
@@ -2320,6 +2383,16 @@ video {
|
||||
color: rgb(255 255 255 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.dark\:hover\:text-blue-300:hover:is(.dark *) {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(147 197 253 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.dark\:hover\:text-red-300:hover:is(.dark *) {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(252 165 165 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.dark\:focus\:border-blue-500:focus:is(.dark *) {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(59 130 246 / var(--tw-border-opacity));
|
||||
@@ -2560,6 +2633,10 @@ video {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.lg\:grid-cols-3 {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.lg\:flex-row {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user