diff --git a/.gitattributes b/.gitattributes
index fab27f433..53f57e659 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,12 +1,17 @@
# Set default behavior to automatically normalize line endings
* text=auto
-# Shell scripts should always use LF (Unix-style) line endings
+# Common files should always use LF (Unix-style) line endings
*.sh text eol=lf
-
-# Batch scripts should always use CRLF (Windows-style) line endings
-*.bat text eol=crlf
-*.cmd text eol=crlf
+*.cs text eol=lf
+*.razor text eol=lf
+*.css text eol=lf
+*.html text eol=lf
+*.js text eol=lf
+*.json text eol=lf
+*.xml text eol=lf
+*.yml text eol=lf
+*.yaml text eol=lf
# Docker files should use LF
Dockerfile text eol=lf
@@ -17,6 +22,10 @@ docker-compose*.yml text eol=lf
*.config text eol=lf
.env* text eol=lf
+# Batch scripts should always use CRLF (Windows-style) line endings
+*.bat text eol=crlf
+*.cmd text eol=crlf
+
# Documentation should be normalized
*.md text
*.txt text
\ No newline at end of file
diff --git a/src/AliasVault.Admin/Main/Pages/Dashboard/Index.razor b/src/AliasVault.Admin/Main/Pages/Dashboard/Index.razor
index 4b466c11d..28c8c40f7 100644
--- a/src/AliasVault.Admin/Main/Pages/Dashboard/Index.razor
+++ b/src/AliasVault.Admin/Main/Pages/Dashboard/Index.razor
@@ -45,6 +45,8 @@
///
protected override async Task OnAfterRenderAsync(bool firstRender)
{
+ await base.OnAfterRenderAsync(firstRender);
+
if (firstRender)
{
await RefreshData();
diff --git a/src/AliasVault.Admin/Main/Pages/MainBase.cs b/src/AliasVault.Admin/Main/Pages/MainBase.cs
index a83febf47..c3abdc081 100644
--- a/src/AliasVault.Admin/Main/Pages/MainBase.cs
+++ b/src/AliasVault.Admin/Main/Pages/MainBase.cs
@@ -12,9 +12,9 @@ using AliasVault.Admin.Services;
using AliasVault.Auth;
using AliasVault.RazorComponents.Models;
using AliasVault.RazorComponents.Services;
+using ApexCharts;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components;
-using Microsoft.EntityFrameworkCore;
using Microsoft.JSInterop;
///
@@ -73,6 +73,12 @@ public abstract class MainBase : OwningComponentBase
[Inject]
protected ConfirmModalService ConfirmModalService { get; set; } = null!;
+ ///
+ /// Gets or sets the ApexChartService.
+ ///
+ [Inject]
+ protected IApexChartService ApexChartService { get; set; } = null!;
+
///
/// Gets or sets the injected JSRuntime instance.
///
@@ -96,6 +102,18 @@ public abstract class MainBase : OwningComponentBase
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "Home", Url = NavigationService.BaseUri });
}
+ ///
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ await base.OnAfterRenderAsync(firstRender);
+
+ if (firstRender)
+ {
+ // Update default ApexCharts chart color based on the dark mode setting.
+ await SetDefaultApexChartOptionsAsync();
+ }
+ }
+
///
/// Gets the username from the authentication state asynchronously.
///
@@ -104,4 +122,50 @@ public abstract class MainBase : OwningComponentBase
{
return UserService.User().UserName ?? "[Unknown]";
}
+
+ ///
+ /// Sets the default ApexCharts chart color based on the dark mode setting.
+ ///
+ private async Task SetDefaultApexChartOptionsAsync()
+ {
+ var darkMode = await JsInvokeService.RetryInvokeWithResultAsync("isDarkMode", TimeSpan.Zero, 5);
+ var options = new ApexChartBaseOptions
+ {
+ Chart = new Chart
+ {
+ ForeColor = darkMode ? "#bbb" : "#555",
+ },
+ Fill = new Fill
+ {
+ Colors = darkMode ?
+ [
+ "#FFB84D", // Bright gold
+ "#8B6CB9", // Darker Purple
+ "#68A890", // Darker Sea Green
+ "#CD5C5C", // Darker Coral
+ "#4F94CD", // Darker Sky Blue
+ "#BA55D3", // Darker Plum
+ "#CDC673", // Darker Khaki
+ "#6B8E23", // Darker Sage Green
+ "#CD853F", // Darker Burlywood
+ "#7B68EE", // Darker Slate Blue
+ ]
+ :
+ [
+ "#FFB366", // Light Orange
+ "#B19CD9", // Light Purple
+ "#98D8C1", // Light Sea Green
+ "#F08080", // Light Coral
+ "#87CEEB", // Sky Blue
+ "#DDA0DD", // Plum
+ "#F0E68C", // Khaki
+ "#9CB071", // Sage Green
+ "#DEB887", // Burlywood
+ "#A7A1E8", // Light Slate Blue
+ ],
+ },
+ };
+
+ await ApexChartService.SetGlobalOptionsAsync(options, false);
+ }
}
diff --git a/src/AliasVault.Admin/Program.cs b/src/AliasVault.Admin/Program.cs
index c093a9240..243cc6ebe 100644
--- a/src/AliasVault.Admin/Program.cs
+++ b/src/AliasVault.Admin/Program.cs
@@ -19,6 +19,7 @@ using AliasVault.Logging;
using AliasVault.RazorComponents.Services;
using AliasVault.Shared.Models.Configuration;
using AliasVault.Shared.Server.Services;
+using ApexCharts;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Identity;
@@ -61,6 +62,7 @@ builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddSingleton(new VersionedContentService(Directory.GetCurrentDirectory() + "/wwwroot"));
+builder.Services.AddApexCharts();
builder.Services.AddAuthentication(options =>
{
diff --git a/src/AliasVault.Admin/Services/JSInvokeService.cs b/src/AliasVault.Admin/Services/JSInvokeService.cs
index efc8d5646..6fc5e5bfb 100644
--- a/src/AliasVault.Admin/Services/JSInvokeService.cs
+++ b/src/AliasVault.Admin/Services/JSInvokeService.cs
@@ -50,4 +50,44 @@ public class JsInvokeService(IJSRuntime js)
// Optionally log that the JS function could not be called after maxAttempts
}
+
+ ///
+ /// Invoke a JavaScript function with retry and exponential backoff that returns a value.
+ ///
+ /// The type of value to return from the JavaScript function.
+ /// The JS function name to call.
+ /// Initial delay before calling the function.
+ /// Maximum attempts before giving up.
+ /// Arguments to pass on to the javascript function.
+ /// The value returned from the JavaScript function.
+ /// Thrown when the JS function could not be called after all attempts.
+ public async Task RetryInvokeWithResultAsync(string functionName, TimeSpan initialDelay, int maxAttempts, params object[] args)
+ {
+ TimeSpan delay = initialDelay;
+
+ for (int attempt = 1; attempt <= maxAttempts; attempt++)
+ {
+ try
+ {
+ bool isDefined = await js.InvokeAsync("isFunctionDefined", functionName);
+ if (isDefined)
+ {
+ return await js.InvokeAsync(functionName, args);
+ }
+ }
+ catch
+ {
+ // Optionally log the exception
+ }
+
+ // Wait for the delay before the next attempt
+ await Task.Delay(delay);
+
+ // Exponential backoff: double the delay for the next attempt
+ delay = TimeSpan.FromTicks(delay.Ticks * 2);
+ }
+
+ // All attempts failed, throw an exception
+ throw new InvalidOperationException($"Failed to invoke JavaScript function '{functionName}' after {maxAttempts} attempts.");
+ }
}
diff --git a/src/AliasVault.Admin/wwwroot/css/tailwind.css b/src/AliasVault.Admin/wwwroot/css/tailwind.css
index 6ebbd6756..419872ae4 100644
--- a/src/AliasVault.Admin/wwwroot/css/tailwind.css
+++ b/src/AliasVault.Admin/wwwroot/css/tailwind.css
@@ -1969,10 +1969,6 @@ video {
background-color: rgb(113 63 18 / var(--tw-bg-opacity));
}
-.dark\:bg-yellow-900\/30:is(.dark *) {
- background-color: rgb(113 63 18 / 0.3);
-}
-
.dark\:bg-opacity-80:is(.dark *) {
--tw-bg-opacity: 0.8;
}
diff --git a/src/AliasVault.Admin/wwwroot/js/utilities.js b/src/AliasVault.Admin/wwwroot/js/utilities.js
index 3fc33831b..a6c44811d 100644
--- a/src/AliasVault.Admin/wwwroot/js/utilities.js
+++ b/src/AliasVault.Admin/wwwroot/js/utilities.js
@@ -14,6 +14,10 @@ window.initTopMenu = function() {
initDarkModeSwitcher();
};
+window.isDarkMode = function() {
+ return document.documentElement.classList.contains('dark');
+};
+
window.registerClickOutsideHandler = (dotNetHelper) => {
document.addEventListener('click', (event) => {
const menu = document.getElementById('userMenuDropdown');