Add vault storage insights page to web app (#1046)

This commit is contained in:
Leendert de Borst
2026-05-04 11:19:08 +02:00
parent 849b2ba8fc
commit d78bcdf327
5 changed files with 559 additions and 0 deletions

View File

@@ -69,6 +69,11 @@
@Localizer["SecuritySettingsNav"]
</NavLink>
</li>
<li>
<NavLink href="/settings/storage-insights" class="block py-2 px-4 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-400 dark:hover:text-white" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
@Localizer["StorageInsightsNav"]
</NavLink>
</li>
<li>
<NavLink href="/settings/import-export" class="block py-2 px-4 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-400 dark:hover:text-white" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
@Localizer["ImportExportNav"]

View File

@@ -0,0 +1,371 @@
@page "/settings/storage-insights"
@inherits MainBase
@using AliasVault.RazorComponents.Tables
@using Microsoft.EntityFrameworkCore
@using Microsoft.Extensions.Localization
<LayoutPageTitle>@Localizer["PageTitle"]</LayoutPageTitle>
<PageHeader
BreadcrumbItems="@BreadcrumbItems"
Title="@Localizer["PageTitle"]"
Description="@Localizer["PageDescription"]">
</PageHeader>
@if (IsLoading)
{
<div class="p-4 mb-4 mx-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<p class="text-gray-600 dark:text-gray-400">@SharedLocalizer["Loading"]</p>
</div>
}
else
{
<div class="p-4 mb-4 mx-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<h3 class="mb-2 text-lg font-medium text-gray-900 dark:text-white">@Localizer["EstimatedTotalTitle"]</h3>
<p class="text-4xl font-semibold text-gray-900 dark:text-white">@FormatSize(_estimatedTotalBytes)</p>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">@Localizer["EstimatedTotalDescription"]</p>
</div>
<div class="p-4 mb-4 mx-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<h3 class="mb-4 text-lg font-medium text-gray-900 dark:text-white">@Localizer["CountsTitle"]</h3>
<div class="grid gap-4 sm:grid-cols-3">
<div class="p-4 border border-gray-200 rounded-lg dark:border-gray-700">
<p class="text-sm text-gray-500 dark:text-gray-400">@Localizer["ItemCountLabel"]</p>
<p class="mt-1 text-2xl font-semibold text-gray-900 dark:text-white">@_totalItems</p>
</div>
<div class="p-4 border border-gray-200 rounded-lg dark:border-gray-700">
<p class="text-sm text-gray-500 dark:text-gray-400">@Localizer["ItemsWithAttachmentsLabel"]</p>
<p class="mt-1 text-2xl font-semibold text-gray-900 dark:text-white">@_itemsWithAttachments</p>
</div>
<div class="p-4 border border-gray-200 rounded-lg dark:border-gray-700">
<p class="text-sm text-gray-500 dark:text-gray-400">@Localizer["ItemsWithLogosLabel"]</p>
<p class="mt-1 text-2xl font-semibold text-gray-900 dark:text-white">@_itemsWithLogos</p>
</div>
</div>
</div>
<div class="p-4 mb-4 mx-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<h3 class="mb-2 text-lg font-medium text-gray-900 dark:text-white">@Localizer["BreakdownTitle"]</h3>
<p class="mb-4 text-sm text-gray-500 dark:text-gray-400">@Localizer["BreakdownDescription"]</p>
@if (_estimatedTotalBytes == 0)
{
<p class="text-sm text-gray-500 dark:text-gray-400">-</p>
}
else
{
<div class="flex w-full h-4 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div class="bg-gray-400 dark:bg-gray-500" style="width: @(Percent(_baseOverheadBytes, _estimatedTotalBytes).ToString("F2", System.Globalization.CultureInfo.InvariantCulture))%;" title="@Localizer["BreakdownBaseOverheadLabel"]"></div>
<div class="bg-blue-500" style="width: @(Percent(_credentialBytes, _estimatedTotalBytes).ToString("F2", System.Globalization.CultureInfo.InvariantCulture))%;" title="@Localizer["BreakdownCredentialsLabel"]"></div>
<div class="bg-amber-500" style="width: @(Percent(_attachmentBytesWithOverhead, _estimatedTotalBytes).ToString("F2", System.Globalization.CultureInfo.InvariantCulture))%;" title="@Localizer["BreakdownAttachmentsLabel"]"></div>
<div class="bg-emerald-500" style="width: @(Percent(_logoBytesWithOverhead, _estimatedTotalBytes).ToString("F2", System.Globalization.CultureInfo.InvariantCulture))%;" title="@Localizer["BreakdownLogosLabel"]"></div>
</div>
<div class="mt-4 grid gap-2 sm:grid-cols-2 lg:grid-cols-4 text-sm">
<div class="flex items-center gap-2">
<span class="inline-block w-3 h-3 rounded-sm bg-gray-400 dark:bg-gray-500"></span>
<span class="text-gray-700 dark:text-gray-300">@Localizer["BreakdownBaseOverheadLabel"]:</span>
<span class="font-medium text-gray-900 dark:text-white">@FormatSize(_baseOverheadBytes) (@Percent(_baseOverheadBytes, _estimatedTotalBytes).ToString("F1")%)</span>
</div>
<div class="flex items-center gap-2">
<span class="inline-block w-3 h-3 rounded-sm bg-blue-500"></span>
<span class="text-gray-700 dark:text-gray-300">@Localizer["BreakdownCredentialsLabel"]:</span>
<span class="font-medium text-gray-900 dark:text-white">@FormatSize(_credentialBytes) (@Percent(_credentialBytes, _estimatedTotalBytes).ToString("F1")%)</span>
</div>
<div class="flex items-center gap-2">
<span class="inline-block w-3 h-3 rounded-sm bg-amber-500"></span>
<span class="text-gray-700 dark:text-gray-300">@Localizer["BreakdownAttachmentsLabel"]:</span>
<span class="font-medium text-gray-900 dark:text-white">@FormatSize(_attachmentBytesWithOverhead) (@Percent(_attachmentBytesWithOverhead, _estimatedTotalBytes).ToString("F1")%)</span>
</div>
<div class="flex items-center gap-2">
<span class="inline-block w-3 h-3 rounded-sm bg-emerald-500"></span>
<span class="text-gray-700 dark:text-gray-300">@Localizer["BreakdownLogosLabel"]:</span>
<span class="font-medium text-gray-900 dark:text-white">@FormatSize(_logoBytesWithOverhead) (@Percent(_logoBytesWithOverhead, _estimatedTotalBytes).ToString("F1")%)</span>
</div>
</div>
}
</div>
<div class="p-4 mb-4 mx-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<h3 class="mb-2 text-lg font-medium text-gray-900 dark:text-white">@Localizer["TopAttachmentsTitle"]</h3>
<p class="mb-4 text-sm text-gray-500 dark:text-gray-400">@Localizer["TopAttachmentsDescription"]</p>
@if (_topAttachments.Count == 0)
{
<p class="text-sm text-gray-500 dark:text-gray-400">-</p>
}
else
{
<SortableTable Columns="@_attachmentColumns" SortColumn="@string.Empty" SortDirection="SortDirection.Descending" OnSortChanged="(_ => Task.CompletedTask)">
@foreach (var attachment in _topAttachments)
{
<SortableTableRow Class="cursor-pointer" OnClick="@(() => NavigateToItem(attachment.ItemId))">
<SortableTableColumn IsPrimary="true">@attachment.Filename</SortableTableColumn>
<SortableTableColumn>@FormatSize(attachment.SizeBytes)</SortableTableColumn>
<SortableTableColumn>@(string.IsNullOrWhiteSpace(attachment.ItemName) ? "-" : attachment.ItemName)</SortableTableColumn>
<SortableTableColumn>@attachment.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd")</SortableTableColumn>
</SortableTableRow>
}
</SortableTable>
}
</div>
<div class="p-4 mb-4 mx-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<h3 class="mb-2 text-lg font-medium text-gray-900 dark:text-white">@Localizer["TopLogosTitle"]</h3>
<p class="mb-4 text-sm text-gray-500 dark:text-gray-400">@Localizer["TopLogosDescription"]</p>
@if (_topLogos.Count == 0)
{
<p class="text-sm text-gray-500 dark:text-gray-400">-</p>
}
else
{
<SortableTable Columns="@_logoColumns" SortColumn="@string.Empty" SortDirection="SortDirection.Descending" OnSortChanged="(_ => Task.CompletedTask)">
@foreach (var logo in _topLogos)
{
<SortableTableRow Class="@(logo.FirstItemId.HasValue ? "cursor-pointer" : string.Empty)" OnClick="@(logo.FirstItemId.HasValue ? EventCallback.Factory.Create(this, () => NavigateToItem(logo.FirstItemId!.Value)) : default)">
<SortableTableColumn IsPrimary="true">@logo.Source</SortableTableColumn>
<SortableTableColumn>@FormatSize(logo.SizeBytes)</SortableTableColumn>
<SortableTableColumn>@(string.IsNullOrWhiteSpace(logo.MimeType) ? "-" : logo.MimeType)</SortableTableColumn>
<SortableTableColumn>@logo.ItemCount @Localizer["ItemsUsingLogoSuffix"]</SortableTableColumn>
</SortableTableRow>
}
</SortableTable>
}
</div>
}
@code {
// Approximate per-row SQLite overhead in bytes. SQLite uses 4 KiB pages by default;
// small rows pack many per page, while large blobs spill into overflow pages. These
// constants cover row headers and the indexed columns AliasClientDbContext defines.
// They are intentionally rough — attachments and logos dominate real vault size.
private const int PerItemOverheadBytes = 200;
private const int PerFieldValueOverheadBytes = 80;
private const int PerAttachmentOverheadBytes = 120;
private const int PerLogoOverheadBytes = 150;
private const int PerTotpOverheadBytes = 100;
private const int PerPasskeyOverheadBytes = 200;
// Empty AliasVault SQLite file is ~200 KB on disk: file header, page allocation
// for each table/index, sqlite_master schema rows, and __EFMigrationsHistory.
// Constant rather than measured because we don't have access to the file size
// here and the value is stable across vaults of the same schema version.
private const int BaseSqliteOverheadBytes = 200 * 1024;
private readonly List<TableColumn> _attachmentColumns =
[
new TableColumn { Title = "Filename", Sortable = false },
new TableColumn { Title = "Size", Sortable = false },
new TableColumn { Title = "Item", Sortable = false },
new TableColumn { Title = "Created", Sortable = false },
];
private readonly List<TableColumn> _logoColumns =
[
new TableColumn { Title = "Source", Sortable = false },
new TableColumn { Title = "Size", Sortable = false },
new TableColumn { Title = "Type", Sortable = false },
new TableColumn { Title = "Used by", Sortable = false },
];
private bool IsLoading { get; set; } = true;
private int _totalItems;
private int _itemsWithAttachments;
private int _itemsWithLogos;
private long _credentialBytes;
private long _attachmentBytesWithOverhead;
private long _logoBytesWithOverhead;
private long _baseOverheadBytes;
private long _estimatedTotalBytes;
private List<AttachmentRow> _topAttachments = [];
private List<LogoRow> _topLogos = [];
private IStringLocalizer Localizer => LocalizerFactory.Create("Pages.Main.Settings.StorageInsights", "AliasVault.Client");
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = Localizer["BreadcrumbTitle"] });
// Localize the table column headers (created at field init time before localizer was available).
_attachmentColumns[0].Title = Localizer["ColumnFilename"];
_attachmentColumns[1].Title = Localizer["ColumnSize"];
_attachmentColumns[2].Title = Localizer["ColumnItem"];
_attachmentColumns[3].Title = Localizer["ColumnCreated"];
_logoColumns[0].Title = Localizer["ColumnWebsiteURL"];
_logoColumns[1].Title = Localizer["ColumnSize"];
_logoColumns[2].Title = Localizer["ColumnMimeType"];
_logoColumns[3].Title = Localizer["ColumnItemCount"];
await LoadStatisticsAsync();
}
private static string FormatSize(long bytes)
{
const double Kib = 1024d;
const double Mib = Kib * 1024d;
if (bytes < Kib)
{
return string.Create(System.Globalization.CultureInfo.CurrentCulture, $"{bytes} B");
}
if (bytes < Mib)
{
return string.Create(System.Globalization.CultureInfo.CurrentCulture, $"{(bytes / Kib):F1} KB");
}
return string.Create(System.Globalization.CultureInfo.CurrentCulture, $"{(bytes / Mib):F1} MB");
}
private static double Percent(long part, long total)
{
return total == 0 ? 0d : Math.Round((double)part / total * 100d, 2);
}
private async Task LoadStatisticsAsync()
{
var ctx = await DbService.GetDbContextAsync();
_totalItems = await ctx.Items.CountAsync(i => !i.IsDeleted && i.DeletedAt == null);
_itemsWithAttachments = await ctx.Attachments
.Where(a => !a.IsDeleted)
.Select(a => a.ItemId)
.Distinct()
.CountAsync();
_itemsWithLogos = await ctx.Items.CountAsync(i => !i.IsDeleted && i.DeletedAt == null && i.LogoId != null);
var attachmentRows = await ctx.Attachments
.Where(a => !a.IsDeleted)
.Select(a => new AttachmentRow
{
Id = a.Id,
Filename = a.Filename,
SizeBytes = a.Blob.Length,
ItemId = a.ItemId,
ItemName = a.Item.Name,
CreatedAt = a.CreatedAt,
})
.ToListAsync();
var logoProjections = await ctx.Logos
.Where(l => !l.IsDeleted && l.FileData != null)
.Select(l => new
{
l.Id,
l.Source,
l.MimeType,
SizeBytes = l.FileData!.Length,
ItemIds = l.Items
.Where(i => !i.IsDeleted && i.DeletedAt == null)
.Select(i => i.Id)
.ToList(),
})
.ToListAsync();
var logoRows = logoProjections
.Select(l => new LogoRow
{
Id = l.Id,
Source = l.Source,
MimeType = l.MimeType,
SizeBytes = l.SizeBytes,
ItemCount = l.ItemIds.Count,
FirstItemId = l.ItemIds.Count > 0 ? l.ItemIds[0] : null,
})
.ToList();
var fieldValueLengths = await ctx.FieldValues
.Where(fv => !fv.IsDeleted)
.Select(fv => fv.Value == null ? 0 : fv.Value.Length)
.ToListAsync();
var fieldValueBytes = fieldValueLengths.Sum(x => (long)x);
var fieldValueCount = fieldValueLengths.Count;
var passkeySizes = await ctx.Passkeys
.Where(p => !p.IsDeleted)
.Select(p => new
{
UserHandleLen = p.UserHandle.Length,
PrfKeyLen = p.PrfKey == null ? 0 : p.PrfKey.Length,
AdditionalDataLen = p.AdditionalData == null ? 0 : p.AdditionalData.Length,
PublicKeyLen = p.PublicKey.Length,
PrivateKeyLen = p.PrivateKey.Length,
})
.ToListAsync();
var passkeyBytes = passkeySizes.Sum(p => (long)(p.UserHandleLen + p.PrfKeyLen + p.AdditionalDataLen + p.PublicKeyLen + p.PrivateKeyLen));
var passkeyCount = passkeySizes.Count;
var totpCount = await ctx.TotpCodes.CountAsync(t => !t.IsDeleted);
var attachmentBytes = attachmentRows.Sum(a => (long)a.SizeBytes);
var logoBytes = logoRows.Sum(l => (long)l.SizeBytes);
_attachmentBytesWithOverhead = attachmentBytes + ((long)attachmentRows.Count * PerAttachmentOverheadBytes);
_logoBytesWithOverhead = logoBytes + ((long)logoRows.Count * PerLogoOverheadBytes);
_credentialBytes = fieldValueBytes
+ passkeyBytes
+ ((long)_totalItems * PerItemOverheadBytes)
+ ((long)fieldValueCount * PerFieldValueOverheadBytes)
+ ((long)passkeyCount * PerPasskeyOverheadBytes)
+ ((long)totpCount * PerTotpOverheadBytes);
_baseOverheadBytes = BaseSqliteOverheadBytes;
_estimatedTotalBytes = _credentialBytes
+ _attachmentBytesWithOverhead
+ _logoBytesWithOverhead
+ _baseOverheadBytes;
_topAttachments = attachmentRows
.OrderByDescending(a => a.SizeBytes)
.Take(10)
.ToList();
_topLogos = logoRows
.OrderByDescending(l => l.SizeBytes)
.Take(10)
.ToList();
IsLoading = false;
StateHasChanged();
}
private void NavigateToItem(Guid itemId)
{
NavigationManager.NavigateTo($"/items/{itemId}");
}
private sealed class AttachmentRow
{
public Guid Id { get; init; }
public string Filename { get; init; } = string.Empty;
public int SizeBytes { get; init; }
public Guid ItemId { get; init; }
public string? ItemName { get; init; }
public DateTime CreatedAt { get; init; }
}
private sealed class LogoRow
{
public Guid Id { get; init; }
public string Source { get; init; } = string.Empty;
public string? MimeType { get; init; }
public int SizeBytes { get; init; }
public int ItemCount { get; init; }
public Guid? FirstItemId { get; init; }
}
}

View File

@@ -39,6 +39,10 @@
<value>Security settings</value>
<comment>Navigation link for security settings</comment>
</data>
<data name="StorageInsightsNav">
<value>Storage insights</value>
<comment>Navigation link for vault storage insights page</comment>
</data>
<data name="ImportExportNav">
<value>Import / Export</value>
<comment>Navigation link for import/export settings</comment>

View File

@@ -0,0 +1,146 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<!-- Page title and description -->
<data name="PageTitle">
<value>Vault storage insights</value>
<comment>Page title for vault storage insights</comment>
</data>
<data name="PageDescription">
<value>See an estimate of your vault size. For best sync performance, it's advised to keep your vault size small and delete items you no longer need.</value>
<comment>Page description for vault storage insights</comment>
</data>
<data name="BreadcrumbTitle">
<value>Storage insights</value>
<comment>Breadcrumb title for storage insights page</comment>
</data>
<!-- Estimated total -->
<data name="EstimatedTotalTitle">
<value>Estimated vault size</value>
<comment>Title for the estimated total vault size card</comment>
</data>
<data name="EstimatedTotalDescription">
<value>This is an approximation calculated from your local data.</value>
<comment>Subtext explaining that the estimate is approximate</comment>
</data>
<!-- Counts -->
<data name="CountsTitle">
<value>Overview</value>
<comment>Title for the counts/overview section</comment>
</data>
<data name="ItemCountLabel">
<value>Items</value>
<comment>Label for total item count card</comment>
</data>
<data name="ItemsWithAttachmentsLabel">
<value>Items with attachments</value>
<comment>Label for count of items that have at least one attachment</comment>
</data>
<data name="ItemsWithLogosLabel">
<value>Items with a logo</value>
<comment>Label for count of items that reference a logo</comment>
</data>
<!-- Breakdown -->
<data name="BreakdownTitle">
<value>Storage usage breakdown</value>
<comment>Title for the breakdown bar section</comment>
</data>
<data name="BreakdownDescription">
<value>Approximate share of vault size by category. Attachments and logos usually take up most of the space.</value>
<comment>Description for the breakdown bar</comment>
</data>
<data name="BreakdownCredentialsLabel">
<value>Credentials</value>
<comment>Legend label for credential data slice</comment>
</data>
<data name="BreakdownAttachmentsLabel">
<value>Attachments</value>
<comment>Legend label for attachments slice</comment>
</data>
<data name="BreakdownLogosLabel">
<value>Logos</value>
<comment>Legend label for logos slice</comment>
</data>
<data name="BreakdownBaseOverheadLabel">
<value>Database overhead</value>
<comment>Legend label for the fixed SQLite base/schema overhead slice</comment>
</data>
<!-- Top attachments table -->
<data name="TopAttachmentsTitle">
<value>Largest attachments</value>
<comment>Title for the top attachments table</comment>
</data>
<data name="TopAttachmentsDescription">
<value>Top 10 largest file attachments. Remove the attachment if you no longer need it.</value>
<comment>Description for the top attachments table</comment>
</data>
<!-- Top logos table -->
<data name="TopLogosTitle">
<value>Largest logos</value>
<comment>Title for the top logos table</comment>
</data>
<data name="TopLogosDescription">
<value>Top 10 largest service logos. Logos are reused for items with the same domain.</value>
<comment>Description for the top logos table</comment>
</data>
<!-- Table column headers -->
<data name="ColumnFilename">
<value>Filename</value>
<comment>Table column header for attachment filename</comment>
</data>
<data name="ColumnSize">
<value>Size</value>
<comment>Table column header for size in KB</comment>
</data>
<data name="ColumnItem">
<value>Item</value>
<comment>Table column header for the parent item name</comment>
</data>
<data name="ColumnCreated">
<value>Created</value>
<comment>Table column header for creation date</comment>
</data>
<data name="ColumnWebsiteURL">
<value>Website URL</value>
<comment>Table column header for logo source domain</comment>
</data>
<data name="ColumnMimeType">
<value>Type</value>
<comment>Table column header for MIME type</comment>
</data>
<data name="ColumnItemCount">
<value>Used by</value>
<comment>Table column header for the number of items using a logo</comment>
</data>
<!-- Misc -->
<data name="ItemsUsingLogoSuffix">
<value>items</value>
<comment>Suffix shown after the count of items using a given logo (e.g. "3 items")</comment>
</data>
</root>

View File

@@ -1638,6 +1638,10 @@ video {
border-radius: 0.375rem;
}
.rounded-sm {
border-radius: 0.125rem;
}
.rounded-b-lg {
border-bottom-right-radius: 0.5rem;
border-bottom-left-radius: 0.5rem;
@@ -1809,6 +1813,11 @@ video {
border-color: rgb(254 240 138 / 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));
@@ -2018,6 +2027,21 @@ video {
background-color: rgb(234 179 8 / var(--tw-bg-opacity));
}
.bg-amber-500 {
--tw-bg-opacity: 1;
background-color: rgb(245 158 11 / var(--tw-bg-opacity));
}
.bg-emerald-500 {
--tw-bg-opacity: 1;
background-color: rgb(16 185 129 / var(--tw-bg-opacity));
}
.bg-yellow-200 {
--tw-bg-opacity: 1;
background-color: rgb(254 240 138 / var(--tw-bg-opacity));
}
.bg-opacity-50 {
--tw-bg-opacity: 0.5;
}
@@ -3294,6 +3318,11 @@ video {
border-color: rgb(133 77 14 / var(--tw-border-opacity));
}
.dark\:border-yellow-600:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(202 138 4 / var(--tw-border-opacity));
}
.dark\:bg-amber-800\/30:is(.dark *) {
background-color: rgb(146 64 14 / 0.3);
}
@@ -4269,6 +4298,10 @@ 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;
}