Persist frontend theme preference

This commit is contained in:
2026-03-21 13:13:26 +01:00
parent 0b7cc846e7
commit 4f1ef770c7
8 changed files with 103 additions and 5 deletions

View File

@@ -20,6 +20,7 @@
<body>
<Routes />
<ReconnectModal />
<script src="@Assets["theme.js"]"></script>
<script src="@Assets["tables.js"]"></script>
<script src="@Assets["_framework/blazor.web.js"]"></script>
</body>

View File

@@ -1,4 +1,6 @@
@inherits LayoutComponentBase
@inherits LayoutComponentBase
@implements IDisposable
@inject RolemasterDb.App.Frontend.AppState.ThemeState ThemeState
<div class="page">
<div class="sidebar">
@@ -17,3 +19,25 @@
<a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span>
</div>
@code {
protected override void OnInitialized()
{
ThemeState.Changed += HandleThemeChanged;
}
protected override Task OnAfterRenderAsync(bool firstRender) =>
firstRender
? ThemeState.InitializeAsync()
: Task.CompletedTask;
private void HandleThemeChanged()
{
_ = InvokeAsync(StateHasChanged);
}
public void Dispose()
{
ThemeState.Changed -= HandleThemeChanged;
}
}

View File

@@ -0,0 +1,6 @@
namespace RolemasterDb.App.Frontend.AppState;
public static class BrowserStorageKeys
{
public const string ThemeMode = "rolemaster.theme.mode";
}

View File

@@ -0,0 +1,12 @@
using Microsoft.JSInterop;
namespace RolemasterDb.App.Frontend.AppState;
public sealed class BrowserStorageService(IJSRuntime jsRuntime)
{
public ValueTask<string?> GetItemAsync(string key) =>
jsRuntime.InvokeAsync<string?>("localStorage.getItem", key);
public ValueTask SetItemAsync(string key, string value) =>
jsRuntime.InvokeVoidAsync("localStorage.setItem", key, value);
}

View File

@@ -1,19 +1,66 @@
using Microsoft.JSInterop;
namespace RolemasterDb.App.Frontend.AppState;
public sealed class ThemeState
public sealed class ThemeState(BrowserStorageService browserStorage, IJSRuntime jsRuntime)
{
private bool isInitialized;
public ThemeMode CurrentMode { get; private set; } = ThemeMode.System;
public event Action? Changed;
public void SetMode(ThemeMode mode)
public async Task InitializeAsync()
{
if (CurrentMode == mode)
if (isInitialized)
{
return;
}
try
{
var storedMode = await browserStorage.GetItemAsync(BrowserStorageKeys.ThemeMode);
CurrentMode = ParseMode(storedMode);
await ApplyThemeAsync(CurrentMode);
isInitialized = true;
Changed?.Invoke();
}
catch (InvalidOperationException)
{
// JS interop is unavailable during prerender. Retry on the next interactive render.
}
}
public async Task SetModeAsync(ThemeMode mode)
{
if (isInitialized && CurrentMode == mode)
{
return;
}
CurrentMode = mode;
await ApplyThemeAsync(mode);
await browserStorage.SetItemAsync(BrowserStorageKeys.ThemeMode, ToStorageValue(mode));
isInitialized = true;
Changed?.Invoke();
}
private Task ApplyThemeAsync(ThemeMode mode) =>
jsRuntime.InvokeVoidAsync("rolemasterTheme.apply", ToStorageValue(mode)).AsTask();
private static ThemeMode ParseMode(string? value) =>
value?.Trim().ToLowerInvariant() switch
{
"light" => ThemeMode.Light,
"dark" => ThemeMode.Dark,
_ => ThemeMode.System
};
private static string ToStorageValue(ThemeMode mode) =>
mode switch
{
ThemeMode.Light => "light",
ThemeMode.Dark => "dark",
_ => "system"
};
}

View File

@@ -12,6 +12,7 @@ builder.Services.AddRazorComponents()
builder.Services.AddDbContextFactory<RolemasterDbContext>(options => options.UseSqlite(connectionString));
builder.Services.AddSingleton<CriticalImportArtifactLocator>();
builder.Services.AddScoped<LookupService>();
builder.Services.AddScoped<BrowserStorageService>();
builder.Services.AddScoped<ThemeState>();
var app = builder.Build();

View File

@@ -0,0 +1,5 @@
window.rolemasterTheme = {
apply(mode) {
document.documentElement.dataset.theme = mode || "system";
}
};