diff --git a/apps/server/AliasVault.Admin/Main/Models/ServerStatistics.cs b/apps/server/AliasVault.Admin/Main/Models/ServerStatistics.cs new file mode 100644 index 000000000..004349b75 --- /dev/null +++ b/apps/server/AliasVault.Admin/Main/Models/ServerStatistics.cs @@ -0,0 +1,49 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Admin.Main.Models; + +/// +/// Model representing comprehensive server statistics and metrics. +/// +public class ServerStatistics +{ + /// + /// Gets or sets the total number of users registered on the server. + /// + public int TotalUsers { get; set; } + + /// + /// Gets or sets the total number of email aliases created. + /// + public int TotalAliases { get; set; } + + /// + /// Gets or sets the total number of emails stored. + /// + public int TotalEmails { get; set; } + + /// + /// Gets or sets the total number of email attachments. + /// + public int TotalEmailAttachments { get; set; } + + /// + /// Gets or sets the list of top users by storage size. + /// + public List TopUsersByStorage { get; set; } = new(); + + /// + /// Gets or sets the list of top users by number of aliases. + /// + public List TopUsersByAliases { get; set; } = new(); + + /// + /// Gets or sets the list of top IP addresses by user activity. + /// + public List TopIpAddresses { get; set; } = new(); +} diff --git a/apps/server/AliasVault.Admin/Main/Models/TopIpAddress.cs b/apps/server/AliasVault.Admin/Main/Models/TopIpAddress.cs new file mode 100644 index 000000000..01473a5fb --- /dev/null +++ b/apps/server/AliasVault.Admin/Main/Models/TopIpAddress.cs @@ -0,0 +1,34 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Admin.Main.Models; + +/// +/// Model representing an IP address with high user activity. +/// +public class TopIpAddress +{ + /// + /// Gets or sets the original (non-anonymized) IP address for filtering. + /// + public string OriginalIpAddress { get; set; } = string.Empty; + + /// + /// Gets or sets the anonymized IP address for display. + /// + public string IpAddress { get; set; } = string.Empty; + + /// + /// Gets or sets the number of unique users from this IP. + /// + public int UniqueUserCount { get; set; } + + /// + /// Gets or sets the last activity timestamp. + /// + public DateTime LastActivity { get; set; } +} diff --git a/apps/server/AliasVault.Admin/Main/Models/TopUserByAliases.cs b/apps/server/AliasVault.Admin/Main/Models/TopUserByAliases.cs new file mode 100644 index 000000000..bc74ae688 --- /dev/null +++ b/apps/server/AliasVault.Admin/Main/Models/TopUserByAliases.cs @@ -0,0 +1,29 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Admin.Main.Models; + +/// +/// Model representing a user with many email aliases. +/// +public class TopUserByAliases +{ + /// + /// Gets or sets the user ID. + /// + public string UserId { get; set; } = string.Empty; + + /// + /// Gets or sets the username. + /// + public string Username { get; set; } = string.Empty; + + /// + /// Gets or sets the number of aliases. + /// + public int AliasCount { get; set; } +} diff --git a/apps/server/AliasVault.Admin/Main/Models/TopUserByStorage.cs b/apps/server/AliasVault.Admin/Main/Models/TopUserByStorage.cs new file mode 100644 index 000000000..9c16ee5e8 --- /dev/null +++ b/apps/server/AliasVault.Admin/Main/Models/TopUserByStorage.cs @@ -0,0 +1,34 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Admin.Main.Models; + +/// +/// Model representing a user with high storage usage. +/// +public class TopUserByStorage +{ + /// + /// Gets or sets the user ID. + /// + public string UserId { get; set; } = string.Empty; + + /// + /// Gets or sets the username. + /// + public string Username { get; set; } = string.Empty; + + /// + /// Gets or sets the storage size in bytes. + /// + public long StorageBytes { get; set; } + + /// + /// Gets or sets the human-readable storage size. + /// + public string StorageDisplaySize { get; set; } = string.Empty; +} diff --git a/apps/server/AliasVault.Admin/Main/Pages/Dashboard/AllTimeStats.razor b/apps/server/AliasVault.Admin/Main/Pages/Dashboard/AllTimeStats.razor new file mode 100644 index 000000000..95c3deea9 --- /dev/null +++ b/apps/server/AliasVault.Admin/Main/Pages/Dashboard/AllTimeStats.razor @@ -0,0 +1,336 @@ +@page "/all-time-stats" +@using AliasVault.Admin.Main.Models +@using AliasVault.Admin.Services +@using AliasVault.Admin.Main.Pages.Dashboard.Components +@using AliasVault.RazorComponents.Tables +@inherits MainBase +@inject StatisticsService StatisticsService + +All-Time Statistics + + + + + + + +
+ +
+
+
+
+

Users

+ @if (_totalUsers.HasValue) + { +

@_totalUsers.Value.ToString("N0")

+ } + else + { +
+ } +
+
+
+ +
+
+
+

Email Aliases

+ @if (_totalAliases.HasValue) + { +

@_totalAliases.Value.ToString("N0")

+ } + else + { +
+ } +
+
+
+ +
+
+
+

Emails

+ @if (_totalEmails.HasValue) + { +

@_totalEmails.Value.ToString("N0")

+ } + else + { +
+ } +
+
+
+ +
+
+
+

Email attachments

+ @if (_totalEmailAttachments.HasValue) + { +

@_totalEmailAttachments.Value.ToString("N0")

+ } + else + { +
+ } +
+
+
+
+ + +
+ +
+
+
+

Top Users by Storage

+

Users with the largest vault storage requirements

+
+
+ @if (_topUsersByStorage != null) + { +
+ + @foreach (var user in _topUsersByStorage) + { + + + + @user.Username + + + @user.StorageDisplaySize + + } + +
+ } + else + { +
+ +
+ } +
+ + +
+
+
+

Top Users by Aliases

+

Users with the most email aliases created

+
+
+ @if (_topUsersByAliases != null) + { +
+ + @foreach (var user in _topUsersByAliases) + { + + + + @user.Username + + + @user.AliasCount.ToString("N0") + + } + +
+ } + else + { +
+ +
+ } +
+
+ + + @if (_topIpAddresses != null && _topIpAddresses.Any()) + { +
+
+
+

Top IP Address Ranges

+

IP ranges with the most associated user accounts (last octet anonymized, successful logins only)

+
+
+
+ + @foreach (var ip in _topIpAddresses) + { + + + + @ip.IpAddress + + + @ip.UniqueUserCount.ToString("N0") + @ip.LastActivity.ToString("MMM dd, yyyy HH:mm") + + } + +
+
+ } + else if (_topIpAddresses != null && !_topIpAddresses.Any()) + { +
+
+ + + +

No IP Address Data

+

IP addresses are fully anonymized or no successful logins recorded.

+
+
+ } + else if (_ipAddressesLoading) + { +
+
+
+

Top IP Address Ranges

+

IP ranges with the most associated user accounts (last octet anonymized, successful logins only)

+
+
+
+ +
+
+ } + + @if (_loadingError) + { +
+
+
+ + + +
+
+

Error Loading Statistics

+

Unable to retrieve some server statistics. Please try refreshing the page.

+
+
+
+ } +
+ +@code { + // Basic statistics (loaded first for immediate feedback) + private int? _totalUsers; + private int? _totalAliases; + private int? _totalEmails; + private int? _totalEmailAttachments; + + // Detailed statistics (loaded separately) + private List? _topUsersByStorage; + private List? _topUsersByAliases; + private List? _topIpAddresses; + private bool _ipAddressesLoading = true; + private bool _loadingError = false; + + private readonly List _storageTableColumns = new() + { + new() { Title = "User", PropertyName = "Username", Sortable = false }, + new() { Title = "Storage", PropertyName = "StorageDisplaySize", Sortable = false } + }; + + private readonly List _aliasTableColumns = new() + { + new() { Title = "User", PropertyName = "Username", Sortable = false }, + new() { Title = "Aliases", PropertyName = "AliasCount", Sortable = false } + }; + + private readonly List _ipTableColumns = new() + { + new() { Title = "IP Range", PropertyName = "IpAddress", Sortable = false }, + new() { Title = "Users", PropertyName = "UniqueUserCount", Sortable = false }, + new() { Title = "Last Activity", PropertyName = "LastActivity", Sortable = false } + }; + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + + // Load statistics asynchronously for better perceived performance + _ = LoadBasicStatisticsAsync(); + _ = LoadDetailedStatisticsAsync(); + } + + private void RefreshData() + { + // Reset all data + _totalUsers = null; + _totalAliases = null; + _totalEmails = null; + _totalEmailAttachments = null; + _topUsersByStorage = null; + _topUsersByAliases = null; + _topIpAddresses = null; + _ipAddressesLoading = true; + _loadingError = false; + + StateHasChanged(); + + // Reload statistics + _ = LoadBasicStatisticsAsync(); + _ = LoadDetailedStatisticsAsync(); + } + + private async Task LoadBasicStatisticsAsync() + { + try + { + var stats = await StatisticsService.GetServerStatisticsAsync(); + + _totalUsers = stats.TotalUsers; + _totalAliases = stats.TotalAliases; + _totalEmails = stats.TotalEmails; + _totalEmailAttachments = stats.TotalEmailAttachments; + + StateHasChanged(); + } + catch (Exception ex) + { + GlobalNotificationService.AddErrorMessage($"Error loading basic statistics: {ex.Message}"); + _loadingError = true; + StateHasChanged(); + } + } + + private async Task LoadDetailedStatisticsAsync() + { + try + { + var stats = await StatisticsService.GetServerStatisticsAsync(); + + _topUsersByStorage = stats.TopUsersByStorage; + _topUsersByAliases = stats.TopUsersByAliases; + _topIpAddresses = stats.TopIpAddresses; + _ipAddressesLoading = false; + + StateHasChanged(); + } + catch (Exception ex) + { + GlobalNotificationService.AddErrorMessage($"Error loading detailed statistics: {ex.Message}"); + _loadingError = true; + _ipAddressesLoading = false; + StateHasChanged(); + } + } +} \ No newline at end of file diff --git a/apps/server/AliasVault.Admin/Main/Pages/Dashboard/Index.razor b/apps/server/AliasVault.Admin/Main/Pages/Dashboard/Index.razor index 28c8c40f7..c4b63c633 100644 --- a/apps/server/AliasVault.Admin/Main/Pages/Dashboard/Index.razor +++ b/apps/server/AliasVault.Admin/Main/Pages/Dashboard/Index.razor @@ -9,6 +9,12 @@ Title="AliasVault Admin" Description="Welcome to the AliasVault admin dashboard. Below you can find statistics about recent activity on this server."> + + + + + All-Time Stats + diff --git a/apps/server/AliasVault.Admin/Main/Pages/Logging/Auth.razor b/apps/server/AliasVault.Admin/Main/Pages/Logging/Auth.razor index d6199e83b..56b0b1d65 100644 --- a/apps/server/AliasVault.Admin/Main/Pages/Logging/Auth.razor +++ b/apps/server/AliasVault.Admin/Main/Pages/Logging/Auth.razor @@ -10,7 +10,7 @@ @@ -25,13 +25,13 @@
-
+
-
+
+
+ +
@@ -128,6 +134,14 @@ else } } + private bool ShowUniqueUsernames { get; set; } = false; + + private void ToggleUniqueUsernames() + { + ShowUniqueUsernames = !ShowUniqueUsernames; + _ = RefreshData(); + } + private string SortColumn { get; set; } = "Id"; private SortDirection SortDirection { get; set; } = SortDirection.Descending; @@ -228,11 +242,33 @@ else query = ApplySort(query); - TotalRecords = await query.CountAsync(); - LogList = await query - .Skip((CurrentPage - 1) * PageSize) - .Take(PageSize) - .ToListAsync(); + // Handle unique usernames filtering after getting all results (since GroupBy with OrderBy in Select is complex for EF) + if (ShowUniqueUsernames) + { + // Get all matching records first + var allLogs = await query.ToListAsync(); + + // Group by username and take the latest entry for each username + var uniqueLogs = allLogs + .GroupBy(x => x.Username) + .Select(g => g.OrderByDescending(x => x.Timestamp).First()) + .ToList(); + + // Apply pagination to the unique results + TotalRecords = uniqueLogs.Count; + LogList = uniqueLogs + .Skip((CurrentPage - 1) * PageSize) + .Take(PageSize) + .ToList(); + } + else + { + TotalRecords = await query.CountAsync(); + LogList = await query + .Skip((CurrentPage - 1) * PageSize) + .Take(PageSize) + .ToListAsync(); + } // Create user lookup dictionary for the current page var usernames = LogList.Select(x => x.Username).Distinct().Where(x => !string.IsNullOrEmpty(x)).ToList(); @@ -296,6 +332,9 @@ else return query; } + /// + /// Show confirmation modal to delete all logs. + /// private async Task DeleteLogsWithConfirmation() { if (await ConfirmModalService.ShowConfirmation("Confirm Delete", "Are you sure you want to delete all logs? This action cannot be undone.")) @@ -304,6 +343,9 @@ else } } + /// + /// Delete all logs. + /// private async Task DeleteLogs() { IsLoading = true; @@ -317,4 +359,24 @@ else IsLoading = false; StateHasChanged(); } + + /// + /// Get the title of the page. + /// + /// The title of the page. + private string GetTitle() + { + if (TotalRecords == 0) + { + return "Auth logs"; + } + + var title = $"Auth logs ({TotalRecords:N0})"; + if (ShowUniqueUsernames) + { + title += " - Unique usernames"; + } + + return title; + } } diff --git a/apps/server/AliasVault.Admin/Program.cs b/apps/server/AliasVault.Admin/Program.cs index 08c80aef4..a6e15d7b0 100644 --- a/apps/server/AliasVault.Admin/Program.cs +++ b/apps/server/AliasVault.Admin/Program.cs @@ -61,6 +61,7 @@ builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddTransient(); builder.Services.AddSingleton(new VersionedContentService(Directory.GetCurrentDirectory() + "/wwwroot")); builder.Services.AddApexCharts(); diff --git a/apps/server/AliasVault.Admin/Services/StatisticsService.cs b/apps/server/AliasVault.Admin/Services/StatisticsService.cs new file mode 100644 index 000000000..4ccf081d7 --- /dev/null +++ b/apps/server/AliasVault.Admin/Services/StatisticsService.cs @@ -0,0 +1,238 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Admin.Services; + +using AliasServerDb; +using AliasVault.Admin.Main.Models; +using Microsoft.EntityFrameworkCore; + +/// +/// Service for gathering comprehensive server statistics and metrics. +/// +public class StatisticsService +{ + private readonly IAliasServerDbContextFactory _contextFactory; + + /// + /// Initializes a new instance of the class. + /// + /// Database context factory. + public StatisticsService(IAliasServerDbContextFactory contextFactory) + { + _contextFactory = contextFactory; + } + + /// + /// Gets comprehensive server statistics including counts, storage metrics, and top users. + /// + /// Server statistics object. + public async Task GetServerStatisticsAsync() + { + var stats = new ServerStatistics(); + + // Get basic counts in parallel + var tasks = new[] + { + GetTotalUsersAsync(), + GetTotalAliasesAsync(), + GetTotalEmailsAsync(), + GetTotalEmailAttachmentsAsync(), + }; + + var results = await Task.WhenAll(tasks); + + stats.TotalUsers = results[0]; + stats.TotalAliases = results[1]; + stats.TotalEmails = results[2]; + stats.TotalEmailAttachments = results[3]; + + // Get top users data + stats.TopUsersByStorage = await GetTopUsersByStorageAsync(); + stats.TopUsersByAliases = await GetTopUsersByAliasesAsync(); + stats.TopIpAddresses = await GetTopIpAddressesAsync(); + + return stats; + } + + /// + /// Formats kilobytes into human-readable format. + /// + /// Number of kilobytes. + /// Formatted string (e.g., "1.5 MB"). + private static string FormatKilobytes(long kilobytes) + { + string[] suffixes = { "KB", "MB", "GB", "TB" }; + int counter = 0; + decimal number = kilobytes; + while (Math.Round(number / 1024) >= 1) + { + number /= 1024; + counter++; + } + + return $"{number:n1} {suffixes[counter]}"; + } + + /// + /// Anonymizes the last octet of an IP address for privacy. + /// + /// The IP address to anonymize. + /// Anonymized IP address. + private static string AnonymizeIpAddress(string ipAddress) + { + if (ipAddress == "x.x.x.x") + { + return ipAddress; + } + + var parts = ipAddress.Split('.'); + if (parts.Length == 4) + { + return $"{parts[0]}.{parts[1]}.{parts[2]}.xxx"; + } + + // Handle IPv6 or other formats by masking the last segment + var lastColonIndex = ipAddress.LastIndexOf(':'); + if (lastColonIndex > 0) + { + return ipAddress[..lastColonIndex] + ":xxx"; + } + + return "xxx.xxx.xxx.xxx"; + } + + /// + /// Gets the total number of users. + /// + /// Total user count. + private async Task GetTotalUsersAsync() + { + await using var context = await _contextFactory.CreateDbContextAsync(); + return await context.AliasVaultUsers.CountAsync(); + } + + /// + /// Gets the total number of email aliases created. + /// + /// Total alias count. + private async Task GetTotalAliasesAsync() + { + await using var context = await _contextFactory.CreateDbContextAsync(); + return await context.UserEmailClaims.CountAsync(); + } + + /// + /// Gets the total number of emails stored. + /// + /// Total email count. + private async Task GetTotalEmailsAsync() + { + await using var context = await _contextFactory.CreateDbContextAsync(); + return await context.Emails.CountAsync(); + } + + /// + /// Gets the total number of email attachments. + /// + /// Total email attachment count. + private async Task GetTotalEmailAttachmentsAsync() + { + await using var context = await _contextFactory.CreateDbContextAsync(); + return await context.EmailAttachments.CountAsync(); + } + + /// + /// Gets the top 10 users by vault storage size. + /// + /// List of top users by storage. + private async Task> GetTopUsersByStorageAsync() + { + await using var context = await _contextFactory.CreateDbContextAsync(); + + // Get the latest vault for each user with their total storage + var topUsers = await context.Vaults + .GroupBy(v => v.UserId) + .Select(g => new + { + UserId = g.Key, + Username = g.First().User.UserName, + TotalStorageBytes = g.OrderByDescending(v => v.Version).First().FileSize, + }) + .OrderByDescending(u => u.TotalStorageBytes) + .Take(10) + .ToListAsync(); + + return topUsers.Select(u => new TopUserByStorage + { + UserId = u.UserId, + Username = u.Username ?? "Unknown", + StorageBytes = u.TotalStorageBytes, + StorageDisplaySize = FormatKilobytes(u.TotalStorageBytes), + }).ToList(); + } + + /// + /// Gets the top 10 users by number of email aliases. + /// + /// List of top users by aliases. + private async Task> GetTopUsersByAliasesAsync() + { + await using var context = await _contextFactory.CreateDbContextAsync(); + var topUsers = await context.UserEmailClaims + .Where(uec => uec.UserId != null) + .GroupBy(uec => uec.UserId) + .Select(g => new + { + UserId = g.Key, + Username = g.First().User!.UserName, + AliasCount = g.Count(), + }) + .OrderByDescending(u => u.AliasCount) + .Take(10) + .ToListAsync(); + + return topUsers.Select(u => new TopUserByAliases + { + UserId = u.UserId!, + Username = u.Username ?? "Unknown", + AliasCount = u.AliasCount, + }).ToList(); + } + + /// + /// Gets the top 10 IP address ranges by number of associated user accounts. + /// Only includes non-anonymized IPs (not "xxx.xxx.xxx.xxx"). + /// + /// List of top IP addresses. + private async Task> GetTopIpAddressesAsync() + { + await using var context = await _contextFactory.CreateDbContextAsync(); + + // Get distinct IP addresses from successful auth logs only, excluding fully anonymized ones + var ipStats = await context.AuthLogs + .Where(al => al.IpAddress != null && al.IpAddress != "xxx.xxx.xxx.xxx" && al.IsSuccess) + .GroupBy(al => al.IpAddress) + .Select(g => new + { + IpAddress = g.Key, + UniqueUsernames = g.Where(al => al.IsSuccess).Select(al => al.Username).Distinct().Count(), + LastActivity = g.Max(al => al.Timestamp), + }) + .OrderByDescending(ip => ip.UniqueUsernames) + .Take(10) + .ToListAsync(); + + return ipStats.Select(ip => new TopIpAddress + { + OriginalIpAddress = ip.IpAddress!, + IpAddress = AnonymizeIpAddress(ip.IpAddress!), + UniqueUserCount = ip.UniqueUsernames, + LastActivity = ip.LastActivity, + }).ToList(); + } +} diff --git a/apps/server/AliasVault.Admin/wwwroot/css/tailwind.css b/apps/server/AliasVault.Admin/wwwroot/css/tailwind.css index ef36aad8e..882327775 100644 --- a/apps/server/AliasVault.Admin/wwwroot/css/tailwind.css +++ b/apps/server/AliasVault.Admin/wwwroot/css/tailwind.css @@ -691,6 +691,10 @@ video { margin-left: 0.75rem; } +.ml-4 { + margin-left: 1rem; +} + .ml-auto { margin-left: auto; } @@ -794,6 +798,10 @@ video { height: 2.5rem; } +.h-12 { + height: 3rem; +} + .h-20 { height: 5rem; } @@ -814,6 +822,10 @@ video { height: 1.5rem; } +.h-64 { + height: 16rem; +} + .h-7 { height: 1.75rem; } @@ -846,6 +858,10 @@ video { width: 2.5rem; } +.w-12 { + width: 3rem; +} + .w-2\/3 { width: 66.666667%; } @@ -894,6 +910,18 @@ video { width: 100%; } +.w-16 { + width: 4rem; +} + +.w-1\/4 { + width: 25%; +} + +.min-w-full { + min-width: 100%; +} + .max-w-2xl { max-width: 42rem; } @@ -928,6 +956,16 @@ video { animation: spin 1s linear infinite; } +@keyframes pulse { + 50% { + opacity: .5; + } +} + +.animate-pulse { + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + .cursor-not-allowed { cursor: not-allowed; } @@ -1004,6 +1042,10 @@ video { gap: 1rem; } +.gap-6 { + gap: 1.5rem; +} + .gap-8 { gap: 2rem; } @@ -1145,10 +1187,19 @@ video { border-bottom-width: 1px; } +.border-b-2 { + border-bottom-width: 2px; +} + .border-l-4 { border-left-width: 4px; } +.border-blue-500 { + --tw-border-opacity: 1; + border-color: rgb(59 130 246 / var(--tw-border-opacity)); +} + .border-gray-200 { --tw-border-opacity: 1; border-color: rgb(229 231 235 / var(--tw-border-opacity)); @@ -1169,6 +1220,11 @@ video { border-color: rgb(244 149 65 / var(--tw-border-opacity)); } +.border-red-200 { + --tw-border-opacity: 1; + border-color: rgb(254 202 202 / var(--tw-border-opacity)); +} + .border-red-500 { --tw-border-opacity: 1; border-color: rgb(239 68 68 / var(--tw-border-opacity)); @@ -1179,6 +1235,26 @@ video { border-color: rgb(234 179 8 / var(--tw-border-opacity)); } +.border-blue-600 { + --tw-border-opacity: 1; + border-color: rgb(37 99 235 / var(--tw-border-opacity)); +} + +.border-orange-600 { + --tw-border-opacity: 1; + border-color: rgb(234 88 12 / var(--tw-border-opacity)); +} + +.border-orange-400 { + --tw-border-opacity: 1; + border-color: rgb(251 146 60 / var(--tw-border-opacity)); +} + +.border-orange-500 { + --tw-border-opacity: 1; + border-color: rgb(249 115 22 / var(--tw-border-opacity)); +} + .bg-blue-100 { --tw-bg-opacity: 1; background-color: rgb(219 234 254 / var(--tw-bg-opacity)); @@ -1274,6 +1350,11 @@ video { background-color: rgb(21 128 61 / var(--tw-bg-opacity)); } +.bg-orange-500 { + --tw-bg-opacity: 1; + background-color: rgb(249 115 22 / var(--tw-bg-opacity)); +} + .bg-primary-100 { --tw-bg-opacity: 1; background-color: rgb(253 222 133 / var(--tw-bg-opacity)); @@ -1304,6 +1385,11 @@ video { background-color: rgb(184 112 47 / var(--tw-bg-opacity)); } +.bg-purple-500 { + --tw-bg-opacity: 1; + background-color: rgb(168 85 247 / var(--tw-bg-opacity)); +} + .bg-red-100 { --tw-bg-opacity: 1; background-color: rgb(254 226 226 / var(--tw-bg-opacity)); @@ -1349,6 +1435,26 @@ video { background-color: rgb(234 179 8 / var(--tw-bg-opacity)); } +.bg-blue-600 { + --tw-bg-opacity: 1; + background-color: rgb(37 99 235 / var(--tw-bg-opacity)); +} + +.bg-orange-600 { + --tw-bg-opacity: 1; + background-color: rgb(234 88 12 / var(--tw-bg-opacity)); +} + +.bg-orange-400 { + --tw-bg-opacity: 1; + background-color: rgb(251 146 60 / var(--tw-bg-opacity)); +} + +.bg-orange-300 { + --tw-bg-opacity: 1; + background-color: rgb(253 186 116 / var(--tw-bg-opacity)); +} + .bg-opacity-50 { --tw-bg-opacity: 0.5; } @@ -1452,6 +1558,11 @@ video { padding-bottom: 0.75rem; } +.py-4 { + padding-top: 1rem; + padding-bottom: 1rem; +} + .py-6 { padding-top: 1.5rem; padding-bottom: 1.5rem; @@ -1462,6 +1573,11 @@ video { padding-bottom: 2rem; } +.px-1 { + padding-left: 0.25rem; + padding-right: 0.25rem; +} + .pb-2 { padding-bottom: 0.5rem; } @@ -1506,11 +1622,24 @@ video { text-align: center; } +.text-right { + text-align: right; +} + +.font-mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace; +} + .text-2xl { font-size: 1.5rem; line-height: 2rem; } +.text-4xl { + font-size: 2.25rem; + line-height: 2.5rem; +} + .text-5xl { font-size: 3rem; line-height: 1; @@ -1541,11 +1670,6 @@ video { line-height: 1rem; } -.text-4xl { - font-size: 2.25rem; - line-height: 2.5rem; -} - .font-bold { font-weight: 700; } @@ -1566,6 +1690,10 @@ video { font-weight: 600; } +.uppercase { + text-transform: uppercase; +} + .italic { font-style: italic; } @@ -1578,6 +1706,10 @@ video { line-height: 2.25rem; } +.tracking-wider { + letter-spacing: 0.05em; +} + .text-blue-600 { --tw-text-opacity: 1; color: rgb(37 99 235 / var(--tw-text-opacity)); @@ -1623,11 +1755,26 @@ video { color: rgb(17 24 39 / var(--tw-text-opacity)); } +.text-green-600 { + --tw-text-opacity: 1; + color: rgb(22 163 74 / var(--tw-text-opacity)); +} + .text-green-800 { --tw-text-opacity: 1; color: rgb(22 101 52 / var(--tw-text-opacity)); } +.text-indigo-600 { + --tw-text-opacity: 1; + color: rgb(79 70 229 / var(--tw-text-opacity)); +} + +.text-orange-600 { + --tw-text-opacity: 1; + color: rgb(234 88 12 / var(--tw-text-opacity)); +} + .text-primary-600 { --tw-text-opacity: 1; color: rgb(214 131 56 / var(--tw-text-opacity)); @@ -1638,6 +1785,16 @@ video { color: rgb(184 112 47 / var(--tw-text-opacity)); } +.text-purple-600 { + --tw-text-opacity: 1; + color: rgb(147 51 234 / var(--tw-text-opacity)); +} + +.text-red-400 { + --tw-text-opacity: 1; + color: rgb(248 113 113 / var(--tw-text-opacity)); +} + .text-red-600 { --tw-text-opacity: 1; color: rgb(220 38 38 / var(--tw-text-opacity)); @@ -1668,6 +1825,11 @@ video { color: rgb(133 77 14 / var(--tw-text-opacity)); } +.text-gray-300 { + --tw-text-opacity: 1; + color: rgb(209 213 219 / var(--tw-text-opacity)); +} + .underline { text-decoration-line: underline; } @@ -1738,6 +1900,10 @@ video { transition-duration: 300ms; } +.duration-200 { + transition-duration: 200ms; +} + .hover\:bg-gray-100:hover { --tw-bg-opacity: 1; background-color: rgb(243 244 246 / var(--tw-bg-opacity)); @@ -1783,6 +1949,26 @@ video { background-color: rgb(153 27 27 / var(--tw-bg-opacity)); } +.hover\:bg-gray-200:hover { + --tw-bg-opacity: 1; + background-color: rgb(229 231 235 / var(--tw-bg-opacity)); +} + +.hover\:bg-blue-700:hover { + --tw-bg-opacity: 1; + background-color: rgb(29 78 216 / var(--tw-bg-opacity)); +} + +.hover\:bg-orange-700:hover { + --tw-bg-opacity: 1; + background-color: rgb(194 65 12 / var(--tw-bg-opacity)); +} + +.hover\:bg-orange-600:hover { + --tw-bg-opacity: 1; + background-color: rgb(234 88 12 / var(--tw-bg-opacity)); +} + .hover\:text-gray-700:hover { --tw-text-opacity: 1; color: rgb(55 65 81 / var(--tw-text-opacity)); @@ -1808,6 +1994,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-blue-600:hover { + --tw-text-opacity: 1; + color: rgb(37 99 235 / var(--tw-text-opacity)); +} + .hover\:underline:hover { text-decoration-line: underline; } @@ -1879,6 +2075,15 @@ video { --tw-ring-color: rgb(252 165 165 / var(--tw-ring-opacity)); } +.focus\:ring-gray-500:focus { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(107 114 128 / var(--tw-ring-opacity)); +} + +.focus\:ring-offset-2:focus { + --tw-ring-offset-width: 2px; +} + .dark\:divide-gray-600:is(.dark *) > :not([hidden]) ~ :not([hidden]) { --tw-divide-opacity: 1; border-color: rgb(75 85 99 / var(--tw-divide-opacity)); @@ -1928,6 +2133,11 @@ video { border-color: rgb(234 179 8 / var(--tw-border-opacity)); } +.dark\:border-red-800:is(.dark *) { + --tw-border-opacity: 1; + border-color: rgb(153 27 27 / var(--tw-border-opacity)); +} + .dark\:bg-blue-800:is(.dark *) { --tw-bg-opacity: 1; background-color: rgb(30 64 175 / var(--tw-bg-opacity)); @@ -2103,6 +2313,31 @@ video { color: rgb(254 240 138 / var(--tw-text-opacity)); } +.dark\:text-green-400:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(74 222 128 / var(--tw-text-opacity)); +} + +.dark\:text-indigo-400:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(129 140 248 / var(--tw-text-opacity)); +} + +.dark\:text-orange-400:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(251 146 60 / var(--tw-text-opacity)); +} + +.dark\:text-purple-400:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(192 132 252 / var(--tw-text-opacity)); +} + +.dark\:text-red-300:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(252 165 165 / 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)); @@ -2157,6 +2392,21 @@ video { color: rgb(255 255 255 / var(--tw-text-opacity)); } +.dark\:hover\:text-gray-200:hover:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(229 231 235 / 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-blue-400:hover:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(96 165 250 / 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)); @@ -2385,6 +2635,14 @@ video { width: auto; } + .lg\:grid-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .lg\:grid-cols-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + .lg\:flex-row { flex-direction: row; }