Add auto refresh to emails page (#976)

This commit is contained in:
Leendert de Borst
2025-06-30 14:14:14 +02:00
committed by Leendert de Borst
parent 70b7063af2
commit d9d84dd90f
5 changed files with 399 additions and 153 deletions

View File

@@ -92,7 +92,7 @@
}
else
{
<div class="flex items-center justify-center h-full text-gray-500 dark:text-gray-400">
<div class="flex bg-gray-50 dark:bg-gray-700 items-center justify-center h-full text-gray-500 dark:text-gray-400">
<div class="text-center">
<svg class="mx-auto h-12 w-12 text-gray-300 dark:text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>

View File

@@ -17,6 +17,10 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"></path>
</svg>
}
@if (IsNewEmail)
{
<div class="w-2 h-2 ml-1 bg-yellow-500 rounded-full animate-pulse flex-shrink-0" title="New email"></div>
}
</div>
<!-- Subject (smaller, below from) -->
<div class="text-sm text-gray-600 dark:text-gray-300 truncate mb-1">
@@ -55,4 +59,10 @@
/// </summary>
[Parameter]
public bool IsSelected { get; set; }
/// <summary>
/// Whether this email is new and should show an indicator.
/// </summary>
[Parameter]
public bool IsNewEmail { get; set; }
}

View File

@@ -13,6 +13,7 @@
@inject HttpClient HttpClient
@inject ILogger<Home> Logger
@inject MinDurationLoadingService LoadingService
@implements IAsyncDisposable
<LayoutPageTitle>Emails</LayoutPageTitle>
@@ -26,6 +27,10 @@
Title="Emails"
Description="You can view all emails received by your private email addresses below.">
<CustomActions>
@if (DbService.Settings.AutoEmailRefresh)
{
<div class="w-3 h-3 mr-2 rounded-full bg-primary-300 border-2 border-primary-100 animate-pulse" title="Auto-refresh enabled"></div>
}
<RefreshButton OnClick="RefreshData" ButtonText="Refresh" />
</CustomActions>
</PageHeader>
@@ -96,7 +101,8 @@ else
<EmailRow
Email="email"
OnEmailClick="ShowAliasVaultEmailInModal"
IsSelected="false" />
IsSelected="false"
IsNewEmail="@(NewEmailIds.Contains(email.Id))" />
}
}
</ul>
@@ -104,65 +110,71 @@ else
</div>
<!-- Desktop Layout (lg and up) - Sidebar and Preview with Load More -->
<div class="hidden lg:flex mt-6 h-[calc(100vh-300px)] min-h-[600px] rounded-lg overflow-hidden">
<!-- Left Sidebar - Email List -->
<div class="w-1/4 bg-white border border-r-0 dark:bg-gray-800 dark:border-gray-700 flex flex-col">
<div class="flex-1 overflow-y-auto" id="email-list-container">
<ul>
@if (EmailList.Count == 0)
{
<li class="p-4 text-center text-gray-500 dark:text-gray-300">
No emails have been received yet.
</li>
}
else
{
@foreach (var email in EmailList)
{
<EmailRow
Email="email"
OnEmailClick="SelectEmailForPreview"
IsSelected="@(SelectedEmailId == email.Id)" />
}
}
<!-- Load More Button for Desktop -->
@if (HasMoreEmails && EmailList.Count > 0)
{
<li class="border-t border-gray-200 dark:border-gray-600 p-3 bg-gray-50 dark:bg-gray-700">
<button @onclick="LoadMoreEmails"
disabled="@IsLoadingMore"
class="w-full px-4 py-2 text-sm font-medium text-primary-600 bg-primary-50 border border-primary-200 rounded-md hover:bg-primary-100 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed dark:text-primary-400 dark:bg-primary-900/20 dark:border-primary-800 dark:hover:bg-primary-900/30">
@if (IsLoadingMore)
{
<span class="flex items-center justify-center">
<svg class="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Loading...
</span>
}
else
{
<span>Load More (@(TotalRecords - EmailList.Count) remaining)</span>
}
</button>
</li>
}
</ul>
<div class="hidden lg:flex mt-6 rounded-lg overflow-hidden">
@if (EmailList.Count == 0)
{
<!-- Single row message for desktop when no emails -->
<div class="w-full bg-white border rounded-lg dark:bg-gray-800 dark:border-gray-700 overflow-hidden">
<div class="p-4 text-center text-gray-500 dark:text-gray-300">
No emails have been received yet.
</div>
</div>
</div>
}
else
{
<div class="w-full h-[calc(100vh-300px)] min-h-[600px] flex rounded-lg overflow-hidden">
<!-- Left Sidebar - Email List -->
<div class="w-1/4 bg-white border border-r-0 dark:bg-gray-800 dark:border-gray-700 flex flex-col">
<div class="flex-1 overflow-y-auto" id="email-list-container">
<ul>
@foreach (var email in EmailList)
{
<EmailRow
Email="email"
OnEmailClick="SelectEmailForPreview"
IsSelected="@(SelectedEmailId == email.Id)"
IsNewEmail="@(NewEmailIds.Contains(email.Id))" />
}
<!-- Load More Button for Desktop -->
@if (HasMoreEmails && EmailList.Count > 0)
{
<li class="border-t border-gray-200 dark:border-gray-600 p-3 bg-gray-50 dark:bg-gray-700">
<button @onclick="LoadMoreEmails"
disabled="@IsLoadingMore"
class="w-full px-4 py-2 text-sm font-medium text-primary-600 bg-primary-50 border border-primary-200 rounded-md hover:bg-primary-100 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed dark:text-primary-400 dark:bg-primary-900/20 dark:border-primary-800 dark:hover:bg-primary-900/30">
@if (IsLoadingMore)
{
<span class="flex items-center justify-center">
<svg class="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Loading...
</span>
}
else
{
<span>Load More (@(TotalRecords - EmailList.Count) remaining)</span>
}
</button>
</li>
}
</ul>
</div>
</div>
<!-- Right Panel - Email Preview -->
<div class="w-3/4">
<EmailPreview
Email="SelectedEmail"
IsSpamOk="false"
OnEmailDeleted="HandleEmailDeleted"
CredentialId="@GetSelectedEmailCredentialId()"
CredentialName="@GetSelectedEmailCredentialName()"
OnCredentialClick="NavigateToCredential" />
</div>
<!-- Right Panel - Email Preview -->
<div class="w-3/4">
<EmailPreview
Email="SelectedEmail"
IsSpamOk="false"
OnEmailDeleted="HandleEmailDeleted"
CredentialId="@GetSelectedEmailCredentialId()"
CredentialName="@GetSelectedEmailCredentialName()"
OnCredentialClick="NavigateToCredential" />
</div>
</div>
}
</div>
</div>
}
@@ -181,6 +193,241 @@ else
private bool IsLoadingMore { get; set; }
private bool HasMoreEmails => TotalRecords > EmailList.Count;
// Auto-refresh related properties
private const int ACTIVE_TAB_REFRESH_INTERVAL = 2000; // 2 seconds
private CancellationTokenSource? _pollingCts;
private DotNetObjectReference<Home>? _dotNetRef;
private bool _isPageVisible = true;
private HashSet<int> NewEmailIds { get; set; } = new();
private HashSet<int> _knownEmailIds = new();
/// <summary>
/// Callback invoked by JavaScript when the page visibility changes. This is used to start/stop the polling for new emails.
/// </summary>
/// <param name="isVisible">Indicates whether the page is visible or not.</param>
[JSInvokable]
public void OnVisibilityChange(bool isVisible)
{
_isPageVisible = isVisible;
if (isVisible && DbService.Settings.AutoEmailRefresh)
{
// Start polling if visible and auto-refresh is enabled
StartPolling();
}
else
{
// Stop polling if hidden
StopPolling();
}
// If becoming visible, do an immediate refresh
if (isVisible)
{
_ = CheckForNewEmails();
}
}
private void StartPolling()
{
// If already polling, no need to start again
if (_pollingCts != null) {
return;
}
_pollingCts = new CancellationTokenSource();
// Start polling task
_ = PollForNewEmails(_pollingCts.Token);
}
private void StopPolling()
{
if (_pollingCts != null)
{
_pollingCts.Cancel();
_pollingCts.Dispose();
_pollingCts = null;
}
}
private async Task PollForNewEmails(CancellationToken cancellationToken)
{
try
{
while (!cancellationToken.IsCancellationRequested)
{
await CheckForNewEmails();
await Task.Delay(ACTIVE_TAB_REFRESH_INTERVAL, cancellationToken);
}
}
catch (OperationCanceledException)
{
// Normal cancellation, ignore.
}
catch (Exception ex)
{
Logger.LogError(ex, "Error in email refresh polling");
}
}
/// <summary>
/// Check for new emails without disrupting the current view.
/// </summary>
private async Task CheckForNewEmails()
{
if (!_isPageVisible || !DbService.Settings.AutoEmailRefresh)
{
return;
}
try
{
var emailClaimList = await DbService.GetEmailClaimListAsync();
if (emailClaimList.Count == 0)
{
return;
}
var requestModel = new MailboxBulkRequest
{
Page = 1,
PageSize = 10, // Only check the latest 10 emails for new ones
Addresses = emailClaimList,
};
var request = new HttpRequestMessage(HttpMethod.Post, $"v1/EmailBox/bulk");
request.Content = new StringContent(JsonSerializer.Serialize(requestModel), Encoding.UTF8, "application/json");
var response = await HttpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
{
var mailbox = await response.Content.ReadFromJsonAsync<MailboxBulkResponse>();
if (mailbox?.Mails != null)
{
var context = await DbService.GetDbContextAsync();
var credentialLookup = await context.Credentials
.Include(x => x.Service)
.Include(x => x.Alias)
.Where(x => x.Alias.Email != null)
.GroupBy(x => x.Alias.Email!.ToLower())
.ToDictionaryAsync(
g => g.Key,
g => new { Id = g.First().Id, ServiceName = g.First().Service.Name ?? "Unknown" }
);
List<MailboxEmailApiModel> decryptedEmailList;
try
{
decryptedEmailList = await EmailService.DecryptEmailList(mailbox.Mails);
}
catch (InvalidOperationException ex) when (ex.Message.Contains("Sequence contains no matching element"))
{
// Handle case where encryption keys are not available for some emails
Logger.LogWarning(ex, "Failed to decrypt some emails due to missing encryption keys");
return;
}
var newEmails = decryptedEmailList.Select(email =>
{
var toEmail = email.ToLocal + "@" + email.ToDomain;
var credentialInfo = credentialLookup.TryGetValue(toEmail.ToLower(), out var info)
? info
: new { Id = Guid.Empty, ServiceName = "Unknown" };
return new MailListViewModel
{
Id = email.Id,
Date = email.DateSystem,
FromName = email.FromDisplay,
FromEmail = email.FromLocal + "@" + email.FromDomain,
ToEmail = toEmail,
Subject = email.Subject,
MessagePreview = email.MessagePreview,
CredentialId = credentialInfo.Id,
CredentialName = credentialInfo.ServiceName,
HasAttachments = email.HasAttachments,
};
}).ToList();
// Check for new emails
var newEmailIds = new HashSet<int>();
foreach (var email in newEmails)
{
if (!_knownEmailIds.Contains(email.Id))
{
newEmailIds.Add(email.Id);
}
}
// Update the known email IDs
foreach (var email in newEmails)
{
_knownEmailIds.Add(email.Id);
}
// Add new emails to the list and mark them as new
if (newEmailIds.Count > 0)
{
// Add new emails to the beginning of the list
var emailsToAdd = newEmails.Where(e => newEmailIds.Contains(e.Id)).ToList();
EmailList.InsertRange(0, emailsToAdd);
// Update total records
TotalRecords += emailsToAdd.Count;
// Mark emails as new
NewEmailIds.UnionWith(newEmailIds);
// Remove new email indicators after 30 seconds
_ = Task.Delay(30000).ContinueWith(_ =>
{
NewEmailIds.ExceptWith(newEmailIds);
InvokeAsync(StateHasChanged);
});
StateHasChanged();
}
}
}
}
catch (Exception ex)
{
Logger.LogError(ex, "An error occurred while checking for new emails");
}
}
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
// Create a single object reference for JS interop
_dotNetRef = DotNetObjectReference.Create(this);
await JsInteropService.RegisterVisibilityCallback(_dotNetRef);
// Only start polling if auto-refresh is enabled and page is visible
if (DbService.Settings.AutoEmailRefresh && _isPageVisible)
{
StartPolling();
}
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
// Stop polling
StopPolling();
// Unregister the visibility callback using the same reference
if (_dotNetRef != null)
{
await JsInteropService.UnregisterVisibilityCallback(_dotNetRef);
_dotNetRef.Dispose();
}
}
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
@@ -211,6 +458,8 @@ else
// Reset pagination for fresh load
CurrentPage = 1;
EmailList.Clear();
NewEmailIds.Clear();
_knownEmailIds.Clear();
var emailClaimList = await DbService.GetEmailClaimListAsync();
@@ -353,6 +602,10 @@ else
else
{
EmailList = newEmails;
// Initialize known email IDs for auto-refresh - don't mark existing emails as new
_knownEmailIds = new HashSet<int>(newEmails.Select(e => e.Id));
// Clear any existing new email indicators since this is the initial load
NewEmailIds.Clear();
}
CurrentPage = model.CurrentPage;
@@ -373,6 +626,9 @@ else
/// </summary>
private async Task ShowAliasVaultEmailInModal(int emailId)
{
// Remove new email mark when email is clicked
NewEmailIds.Remove(emailId);
EmailApiModel? mail = await HttpClient.GetFromJsonAsync<EmailApiModel>($"v1/Email/{emailId}");
if (mail != null)
{
@@ -412,6 +668,9 @@ else
/// </summary>
private async Task SelectEmailForPreview(int emailId)
{
// Remove new email mark when email is clicked
NewEmailIds.Remove(emailId);
SelectedEmailId = emailId;
await LoadSelectedEmailForPreview(emailId);
}
@@ -457,6 +716,10 @@ else
SelectedEmail = null;
}
// Remove from new email indicators
NewEmailIds.Remove(emailId);
_knownEmailIds.Remove(emailId);
// Refresh the email list
await RefreshData();
}

View File

@@ -38,7 +38,7 @@
<div class="flex items-center mb-4">
<input @bind="AutoEmailRefresh" @bind:after="UpdateAutoEmailRefresh" id="autoEmailRefresh" type="checkbox" class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
<label for="autoEmailRefresh" class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300">Auto email refresh on credential page</label>
<label for="autoEmailRefresh" class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300">Auto refresh emails content when new ones arrive</label>
</div>
</div>

View File

@@ -945,10 +945,6 @@ video {
height: 2.25rem;
}
.h-\[calc\(100vh-300px\)\] {
height: calc(100vh - 300px);
}
.h-full {
height: 100%;
}
@@ -957,26 +953,30 @@ video {
height: 1px;
}
.max-h-32 {
max-height: 8rem;
.h-\[calc\(100vh-300px\)\] {
height: calc(100vh - 300px);
}
.max-h-\[90vh\] {
max-height: 90vh;
}
.max-h-32 {
max-height: 8rem;
}
.min-h-\[250px\] {
min-height: 250px;
}
.min-h-\[600px\] {
min-height: 600px;
}
.min-h-screen {
min-height: 100vh;
}
.min-h-\[600px\] {
min-height: 600px;
}
.w-1 {
width: 0.25rem;
}
@@ -989,14 +989,6 @@ video {
width: 50%;
}
.w-1\/3 {
width: 33.333333%;
}
.w-1\/4 {
width: 25%;
}
.w-10 {
width: 2.5rem;
}
@@ -1033,10 +1025,6 @@ video {
width: 1rem;
}
.w-40 {
width: 10rem;
}
.w-48 {
width: 12rem;
}
@@ -1045,6 +1033,10 @@ video {
width: 1.25rem;
}
.w-56 {
width: 14rem;
}
.w-6 {
width: 1.5rem;
}
@@ -1069,8 +1061,20 @@ video {
width: 100vw;
}
.w-56 {
width: 14rem;
.w-1\/3 {
width: 33.333333%;
}
.w-1\/4 {
width: 25%;
}
.w-40 {
width: 10rem;
}
.w-2 {
width: 0.5rem;
}
.min-w-0 {
@@ -1279,12 +1283,6 @@ video {
margin-left: calc(1.5rem * calc(1 - var(--tw-space-x-reverse)));
}
.space-y-1 > :not([hidden]) ~ :not([hidden]) {
--tw-space-y-reverse: 0;
margin-top: calc(0.25rem * calc(1 - var(--tw-space-y-reverse)));
margin-bottom: calc(0.25rem * var(--tw-space-y-reverse));
}
.space-y-2 > :not([hidden]) ~ :not([hidden]) {
--tw-space-y-reverse: 0;
margin-top: calc(0.5rem * calc(1 - var(--tw-space-y-reverse)));
@@ -1309,6 +1307,12 @@ video {
margin-bottom: calc(1.5rem * var(--tw-space-y-reverse));
}
.space-y-1 > :not([hidden]) ~ :not([hidden]) {
--tw-space-y-reverse: 0;
margin-top: calc(0.25rem * calc(1 - var(--tw-space-y-reverse)));
margin-bottom: calc(0.25rem * var(--tw-space-y-reverse));
}
.divide-y > :not([hidden]) ~ :not([hidden]) {
--tw-divide-y-reverse: 0;
border-top-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));
@@ -1406,14 +1410,14 @@ video {
border-width: 1px;
}
.border-0 {
border-width: 0px;
}
.border-2 {
border-width: 2px;
}
.border-0 {
border-width: 0px;
}
.border-b {
border-bottom-width: 1px;
}
@@ -1446,25 +1450,11 @@ video {
border-right-width: 0px;
}
.border-dashed {
border-style: dashed;
}
.border-amber-400 {
--tw-border-opacity: 1;
border-color: rgb(251 191 36 / var(--tw-border-opacity));
}
.border-blue-200 {
--tw-border-opacity: 1;
border-color: rgb(191 219 254 / var(--tw-border-opacity));
}
.border-blue-500 {
--tw-border-opacity: 1;
border-color: rgb(59 130 246 / var(--tw-border-opacity));
}
.border-blue-700 {
--tw-border-opacity: 1;
border-color: rgb(29 78 216 / var(--tw-border-opacity));
@@ -1485,11 +1475,6 @@ video {
border-color: rgb(209 213 219 / var(--tw-border-opacity));
}
.border-gray-400 {
--tw-border-opacity: 1;
border-color: rgb(156 163 175 / var(--tw-border-opacity));
}
.border-green-500 {
--tw-border-opacity: 1;
border-color: rgb(34 197 94 / var(--tw-border-opacity));
@@ -1530,6 +1515,11 @@ video {
border-color: rgb(251 203 116 / var(--tw-border-opacity));
}
.border-yellow-400 {
--tw-border-opacity: 1;
border-color: rgb(250 204 21 / var(--tw-border-opacity));
}
.bg-amber-100 {
--tw-bg-opacity: 1;
background-color: rgb(254 243 199 / var(--tw-bg-opacity));
@@ -1704,6 +1694,11 @@ video {
background-color: rgb(255 224 150 / var(--tw-bg-opacity));
}
.bg-yellow-500 {
--tw-bg-opacity: 1;
background-color: rgb(234 179 8 / var(--tw-bg-opacity));
}
.bg-opacity-50 {
--tw-bg-opacity: 0.5;
}
@@ -2045,11 +2040,6 @@ video {
color: rgb(229 231 235 / var(--tw-text-opacity));
}
.text-gray-300 {
--tw-text-opacity: 1;
color: rgb(209 213 219 / var(--tw-text-opacity));
}
.text-gray-400 {
--tw-text-opacity: 1;
color: rgb(156 163 175 / var(--tw-text-opacity));
@@ -2155,6 +2145,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;
}
@@ -2167,14 +2162,14 @@ video {
opacity: 1;
}
.opacity-25 {
opacity: 0.25;
}
.opacity-50 {
opacity: 0.5;
}
.opacity-25 {
opacity: 0.25;
}
.opacity-75 {
opacity: 0.75;
}
@@ -2311,11 +2306,6 @@ video {
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.hover\:bg-blue-100:hover {
--tw-bg-opacity: 1;
background-color: rgb(219 234 254 / var(--tw-bg-opacity));
}
.hover\:bg-blue-600:hover {
--tw-bg-opacity: 1;
background-color: rgb(37 99 235 / var(--tw-bg-opacity));
@@ -2638,11 +2628,6 @@ video {
border-color: rgb(59 130 246 / var(--tw-border-opacity));
}
.dark\:border-blue-800:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(30 64 175 / var(--tw-border-opacity));
}
.dark\:border-gray-400:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(156 163 175 / var(--tw-border-opacity));
@@ -2726,14 +2711,6 @@ video {
background-color: rgb(30 58 138 / var(--tw-bg-opacity));
}
.dark\:bg-blue-900\/20:is(.dark *) {
background-color: rgb(30 58 138 / 0.2);
}
.dark\:bg-blue-900\/30:is(.dark *) {
background-color: rgb(30 58 138 / 0.3);
}
.dark\:bg-gray-500:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(107 114 128 / var(--tw-bg-opacity));
@@ -2830,6 +2807,10 @@ video {
background-color: rgb(123 74 30 / 0.3);
}
.dark\:bg-yellow-900\/20:is(.dark *) {
background-color: rgb(113 63 18 / 0.2);
}
.dark\:bg-opacity-80:is(.dark *) {
--tw-bg-opacity: 0.8;
}
@@ -2992,10 +2973,6 @@ video {
background-color: rgb(29 78 216 / var(--tw-bg-opacity));
}
.dark\:hover\:bg-blue-900\/30:hover:is(.dark *) {
background-color: rgb(30 58 138 / 0.3);
}
.dark\:hover\:bg-gray-500:hover:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(107 114 128 / var(--tw-bg-opacity));
@@ -3090,11 +3067,6 @@ video {
color: rgb(244 149 65 / 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\:hover\:text-red-400:hover:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(248 113 113 / var(--tw-text-opacity));
@@ -3105,6 +3077,11 @@ video {
color: rgb(255 255 255 / 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\:hover\:file\:bg-primary-800\/60:is(.dark *)::file-selector-button:hover {
background-color: rgb(154 93 38 / 0.6);
}
@@ -3423,10 +3400,6 @@ video {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.lg\:grid-cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.lg\:flex-row {
flex-direction: row;
}