diff --git a/README.md b/README.md index 5677925..ec61e5b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# RpgRoller +# RpgRoller RpgRoller is an ASP.NET Core and Blazor Server app for lightweight tabletop campaign play, character sheets, and dice workflows. @@ -54,7 +54,8 @@ Frontend: - `RpgRoller/Components/RpgRollerApiClient.cs`: browser API client for write actions - `RpgRoller/Components/WorkspaceQueryService.cs`: browser-facing read client for workspace data - `RpgRoller/wwwroot/js/rpgroller-api.js`: browser interop for auth forms, session storage, SSE wiring, and DOM helpers -- `RpgRoller/wwwroot/styles.css`: app styling and responsive layout +- `RpgRoller/wwwroot/styles.css`: app styling, light and dark theme variables, and responsive layout +- `RpgRoller/wwwroot/images/light.png` and `RpgRoller/wwwroot/images/dark.png`: themed workspace background art Current repo note: @@ -75,6 +76,7 @@ Current repo note: - Supported campaign rulesets: D6 System, D&D 5e, and Rolemaster - Account registration, login, session-based auth, and role-aware authorization - Admin tools for user listing, role updates, account deletion, and direct SQLite database download +- Per-user light and dark theme preference with OS-based initial selection - Campaign creation, roster reads, participant-scoped visibility, and owner and admin deletion - Character creation, activation, owner transfer, campaign reassignment or unlinking, and owner and admin deletion - Skill groups with reusable defaults plus skill and skill-group create, edit, reassign, and delete flows diff --git a/RpgRoller.Tests/Api/AuthApiTests.cs b/RpgRoller.Tests/Api/AuthApiTests.cs index c0ace95..b956637 100644 --- a/RpgRoller.Tests/Api/AuthApiTests.cs +++ b/RpgRoller.Tests/Api/AuthApiTests.cs @@ -1,4 +1,4 @@ -namespace RpgRoller.Tests; +namespace RpgRoller.Tests; public sealed class AuthApiTests(WebApplicationFactory factory) : ApiTestBase(factory) { @@ -12,8 +12,7 @@ public sealed class AuthApiTests(WebApplicationFactory factory) : ApiTe Assert.Equal("alice", registerResult.Username); Assert.Contains(registerResult.Roles, role => string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase)); - var duplicate = await client.PostAsJsonAsync("/api/auth/register", - new RegisterRequest("alice", "Password123", "Alice 2")); + var duplicate = await client.PostAsJsonAsync("/api/auth/register", new RegisterRequest("alice", "Password123", "Alice 2")); Assert.Equal(HttpStatusCode.BadRequest, duplicate.StatusCode); var loginResult = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest("alice", "Password123")); @@ -21,13 +20,39 @@ public sealed class AuthApiTests(WebApplicationFactory factory) : ApiTe var me = await GetAsync(client, "/api/me"); Assert.Equal(registerResult.Id, me.User.Id); + Assert.Null(me.User.ThemePreference); Assert.Null(me.ActiveCharacterId); Assert.Null(me.CurrentCampaignId); + var themeUser = await PutAsync(client, "/api/me/theme", new("dark")); + Assert.Equal("dark", themeUser.ThemePreference); + + var themedMe = await GetAsync(client, "/api/me"); + Assert.Equal("dark", themedMe.User.ThemePreference); + var invalidLogin = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest("alice", "wrong-password")); Assert.Equal(HttpStatusCode.BadRequest, invalidLogin.StatusCode); } + [Fact] + public async Task ThemePreferenceEndpoint_RequiresAuthAndValidTheme() + { + using var factory = CreateFactory(); + using var client = factory.CreateClient(new() { AllowAutoRedirect = false }); + + var unauthorized = await client.PutAsJsonAsync("/api/me/theme", new UpdateThemePreferenceRequest("dark")); + Assert.Equal(HttpStatusCode.Unauthorized, unauthorized.StatusCode); + + var unauthorizedInvalid = await client.PutAsJsonAsync("/api/me/theme", new UpdateThemePreferenceRequest("sepia")); + Assert.Equal(HttpStatusCode.Unauthorized, unauthorizedInvalid.StatusCode); + + await RegisterAsync(client, "theme-api", "Password123", "Theme Api"); + await LoginAsync(client, "theme-api", "Password123"); + + var invalid = await client.PutAsJsonAsync("/api/me/theme", new UpdateThemePreferenceRequest("sepia")); + Assert.Equal(HttpStatusCode.BadRequest, invalid.StatusCode); + } + [Fact] public async Task UsernamesEndpoint_RequiresAuthAndReturnsAlphabeticalList() { @@ -54,10 +79,7 @@ public sealed class AuthApiTests(WebApplicationFactory factory) : ApiTe await RegisterAsync(client, "proxy-user", "Password123", "Proxy User"); - using var request = new HttpRequestMessage(HttpMethod.Post, "/api/auth/login") - { - Content = JsonContent.Create(new LoginRequest("proxy-user", "Password123")) - }; + using var request = new HttpRequestMessage(HttpMethod.Post, "/api/auth/login") { Content = JsonContent.Create(new LoginRequest("proxy-user", "Password123")) }; request.Headers.TryAddWithoutValidation("X-Forwarded-Proto", "https"); using var response = await client.SendAsync(request); diff --git a/RpgRoller.Tests/HostingCoverageTests.cs b/RpgRoller.Tests/HostingCoverageTests.cs index 754ea81..d43b29e 100644 --- a/RpgRoller.Tests/HostingCoverageTests.cs +++ b/RpgRoller.Tests/HostingCoverageTests.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Builder; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -159,6 +159,7 @@ public sealed class HostingCoverageTests usersColumns.Add(usersTableInfoReader.GetString(1)); Assert.Contains("Roles", usersColumns); + Assert.Contains("ThemePreference", usersColumns); using var usersRoleCommand = verifyConnection.CreateCommand(); usersRoleCommand.CommandText = "SELECT Roles FROM Users WHERE UsernameNormalized = 'LEGACY-ADMIN';"; @@ -214,6 +215,11 @@ public sealed class HostingCoverageTests retryHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260414204309_AddRolemasterAutoRetry';"; var retryHistoryCount = Convert.ToInt32(retryHistoryCommand.ExecuteScalar()); Assert.Equal(1, retryHistoryCount); + + using var themeHistoryCommand = verifyConnection.CreateCommand(); + themeHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260518183838_AddUserThemePreference';"; + var themeHistoryCount = Convert.ToInt32(themeHistoryCommand.ExecuteScalar()); + Assert.Equal(1, themeHistoryCount); } [Fact] @@ -359,6 +365,11 @@ public sealed class HostingCoverageTests retryHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260414204309_AddRolemasterAutoRetry';"; var retryHistoryCount = Convert.ToInt32(retryHistoryCommand.ExecuteScalar()); Assert.Equal(1, retryHistoryCount); + + using var themeHistoryCommand = verifyConnection.CreateCommand(); + themeHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260518183838_AddUserThemePreference';"; + var themeHistoryCount = Convert.ToInt32(themeHistoryCommand.ExecuteScalar()); + Assert.Equal(1, themeHistoryCount); } [Fact] @@ -481,6 +492,15 @@ public sealed class HostingCoverageTests Assert.Contains("FumbleRange", skillGroupColumns); + using var usersTableInfoCommand = verifyConnection.CreateCommand(); + usersTableInfoCommand.CommandText = "PRAGMA table_info('Users');"; + using var usersTableInfoReader = usersTableInfoCommand.ExecuteReader(); + var usersColumns = new HashSet(StringComparer.OrdinalIgnoreCase); + while (usersTableInfoReader.Read()) + usersColumns.Add(usersTableInfoReader.GetString(1)); + + Assert.Contains("ThemePreference", usersColumns); + using var authorizationRolesHistoryCommand = verifyConnection.CreateCommand(); authorizationRolesHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226170000_AddAuthorizationRoles';"; var authorizationRolesHistoryCount = Convert.ToInt32(authorizationRolesHistoryCommand.ExecuteScalar()); @@ -490,5 +510,10 @@ public sealed class HostingCoverageTests retryHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260414204309_AddRolemasterAutoRetry';"; var retryHistoryCount = Convert.ToInt32(retryHistoryCommand.ExecuteScalar()); Assert.Equal(1, retryHistoryCount); + + using var themeHistoryCommand = verifyConnection.CreateCommand(); + themeHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260518183838_AddUserThemePreference';"; + var themeHistoryCount = Convert.ToInt32(themeHistoryCommand.ExecuteScalar()); + Assert.Equal(1, themeHistoryCount); } } \ No newline at end of file diff --git a/RpgRoller.Tests/Services/ServiceAuthTests.cs b/RpgRoller.Tests/Services/ServiceAuthTests.cs index b63cd73..4a596fa 100644 --- a/RpgRoller.Tests/Services/ServiceAuthTests.cs +++ b/RpgRoller.Tests/Services/ServiceAuthTests.cs @@ -1,4 +1,4 @@ -namespace RpgRoller.Tests; +namespace RpgRoller.Tests; public sealed class ServiceAuthTests { @@ -74,4 +74,26 @@ public sealed class ServiceAuthTests var usernames = ServiceTestSupport.GetValue(service.GetUsernames(session)); Assert.Equal(["amy", "bob", "zoe"], usernames); } + + [Fact] + public void UpdateThemePreference_RequiresAuthAndPersistsSupportedTheme() + { + using var harness = ServiceTestSupport.CreateHarness(); + var service = harness.Service; + + service.Register("theme-user", "Password123", "Theme User"); + var session = ServiceTestSupport.GetValue(service.Login("theme-user", "Password123")).SessionToken; + + var unauthorized = service.UpdateThemePreference(string.Empty, "dark"); + var invalid = service.UpdateThemePreference(session, "sepia"); + var updated = service.UpdateThemePreference(session, "DARK"); + + Assert.False(unauthorized.Succeeded); + Assert.False(invalid.Succeeded); + Assert.True(updated.Succeeded); + Assert.Equal("dark", ServiceTestSupport.GetValue(updated).ThemePreference); + + var me = ServiceTestSupport.GetValue(service.GetMe(session)); + Assert.Equal("dark", me.User.ThemePreference); + } } \ No newline at end of file diff --git a/RpgRoller.Tests/Services/ServicePersistenceTests.cs b/RpgRoller.Tests/Services/ServicePersistenceTests.cs index 48484a6..1ff924f 100644 --- a/RpgRoller.Tests/Services/ServicePersistenceTests.cs +++ b/RpgRoller.Tests/Services/ServicePersistenceTests.cs @@ -1,4 +1,4 @@ -namespace RpgRoller.Tests; +namespace RpgRoller.Tests; public sealed class ServicePersistenceTests { @@ -22,8 +22,7 @@ public sealed class ServicePersistenceTests var otherSession = ServiceTestSupport.GetValue(service.Login("other", "Password123")).SessionToken; var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Main", "d6")); - var ownerCharacter = - ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Owner Character", campaign.Id)); + var ownerCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Owner Character", campaign.Id)); Assert.False(service.GetMe(string.Empty).Succeeded); Assert.False(service.CreateCampaign(gmSession, "", "d6").Succeeded); @@ -80,8 +79,7 @@ public sealed class ServicePersistenceTests Assert.NotNull(db.Users.Single(u => u.UsernameNormalized == "OWNER").ActiveCharacterId); } - var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, "Stealth", "2D+1", - 1, true)); + var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, "Stealth", "2D+1", 1, true)); Assert.False(service.UpdateSkill(ownerSession, skill.Id, "", "2D+1", 1, true).Succeeded); Assert.False(service.UpdateSkill(string.Empty, skill.Id, "Stealth", "2D+1", 1, true).Succeeded); Assert.False(service.UpdateSkill(ownerSession, skill.Id, "Stealth", "bad", 1, true).Succeeded); @@ -111,17 +109,13 @@ public sealed class ServicePersistenceTests var gmSession = ServiceTestSupport.GetValue(service.Login("gm-rm-persist", "Password123")).SessionToken; var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-rm-persist", "Password123")).SessionToken; - var campaign = - ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Rolemaster Persistence", "rolemaster")); + var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Rolemaster Persistence", "rolemaster")); var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Loremaster", campaign.Id)); - var group = ServiceTestSupport.GetValue(service.CreateSkillGroup(ownerSession, character.Id, "Perception", - "d100!+25", 0, false, 5)); - var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Read Runes", - "d100!+35", 0, false, group.Id, 3, true)); + var group = ServiceTestSupport.GetValue(service.CreateSkillGroup(ownerSession, character.Id, "Perception", "d100!+25", 0, false, 5)); + var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Read Runes", "d100!+35", 0, false, group.Id, 3, true)); using var reloadedHarness = ServiceTestSupport.CreateHarnessFromPath(harness.DbPath); - var reloadedSheet = - ServiceTestSupport.GetValue(reloadedHarness.Service.GetCharacterSheet(ownerSession, character.Id)); + var reloadedSheet = ServiceTestSupport.GetValue(reloadedHarness.Service.GetCharacterSheet(ownerSession, character.Id)); var reloadedGroup = Assert.Single(reloadedSheet.SkillGroups, current => current.Id == group.Id); Assert.Equal(5, reloadedGroup.FumbleRange); @@ -130,4 +124,22 @@ public sealed class ServicePersistenceTests Assert.Equal(3, reloadedSkill.FumbleRange); Assert.True(reloadedSkill.RolemasterAutoRetry); } + + [Fact] + public void UserThemePreference_PersistsAcrossDatabaseReload() + { + using var harness = ServiceTestSupport.CreateHarness(); + var service = harness.Service; + + service.Register("theme-persist", "Password123", "Theme Persist"); + var session = ServiceTestSupport.GetValue(service.Login("theme-persist", "Password123")).SessionToken; + + var updated = ServiceTestSupport.GetValue(service.UpdateThemePreference(session, "dark")); + Assert.Equal("dark", updated.ThemePreference); + + using var reloadedHarness = ServiceTestSupport.CreateHarnessFromPath(harness.DbPath); + var me = ServiceTestSupport.GetValue(reloadedHarness.Service.GetMe(session)); + + Assert.Equal("dark", me.User.ThemePreference); + } } \ No newline at end of file diff --git a/RpgRoller/Api/MeEndpoints.cs b/RpgRoller/Api/MeEndpoints.cs index 10dc246..d33fa25 100644 --- a/RpgRoller/Api/MeEndpoints.cs +++ b/RpgRoller/Api/MeEndpoints.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Http.HttpResults; using RpgRoller.Contracts; using RpgRoller.Services; @@ -14,6 +14,12 @@ internal static class MeEndpoints return ApiResultMapper.ToApiResult(result); }); + group.MapPut("/me/theme", Results, BadRequest, UnauthorizedHttpResult> (UpdateThemePreferenceRequest request, HttpContext context, IGameService game) => + { + var result = game.UpdateThemePreference(context.GetRequiredSessionToken(), request.ThemePreference); + return ApiResultMapper.ToApiResult(result); + }); + return group; } } \ No newline at end of file diff --git a/RpgRoller/App_Data/rpgroller.development.db b/RpgRoller/App_Data/rpgroller.development.db index 4a7aaff..5f17017 100644 Binary files a/RpgRoller/App_Data/rpgroller.development.db and b/RpgRoller/App_Data/rpgroller.development.db differ diff --git a/RpgRoller/Components/App.razor b/RpgRoller/Components/App.razor index 7fe0908..fc859e6 100644 --- a/RpgRoller/Components/App.razor +++ b/RpgRoller/Components/App.razor @@ -1,4 +1,4 @@ -@using RpgRoller.Components.Pages.HomeControls +@using RpgRoller.Components.Pages.HomeControls @attribute [ExcludeFromCodeCoverage] @@ -8,6 +8,12 @@ RpgRoller + diff --git a/RpgRoller/Components/Pages/HomeControls/AppHeader.razor b/RpgRoller/Components/Pages/HomeControls/AppHeader.razor index ffbeddf..b0abb30 100644 --- a/RpgRoller/Components/Pages/HomeControls/AppHeader.razor +++ b/RpgRoller/Components/Pages/HomeControls/AppHeader.razor @@ -40,6 +40,13 @@ } Logout + @if (MenuItems.Count > 0) {
diff --git a/RpgRoller/Components/Pages/HomeControls/AppHeader.razor.cs b/RpgRoller/Components/Pages/HomeControls/AppHeader.razor.cs index 949b412..e575028 100644 --- a/RpgRoller/Components/Pages/HomeControls/AppHeader.razor.cs +++ b/RpgRoller/Components/Pages/HomeControls/AppHeader.razor.cs @@ -12,37 +12,64 @@ public partial class AppHeader return item.OnSelected?.Invoke() ?? Task.CompletedTask; } - [Parameter] public string Title { get; set; } = "RpgRoller"; + private string ThemeToggleAriaLabel => string.Equals(Theme, "dark", StringComparison.OrdinalIgnoreCase) ? "Switch to light theme" : "Switch to dark theme"; - [Parameter] public UserSummary? User { get; set; } + [Parameter] + public string Title { get; set; } = "RpgRoller"; - [Parameter] public bool ShowCampaign { get; set; } + [Parameter] + public UserSummary? User { get; set; } - [Parameter] public IReadOnlyList Campaigns { get; set; } = []; + [Parameter] + public bool ShowCampaign { get; set; } - [Parameter] public Guid? SelectedCampaignId { get; set; } + [Parameter] + public IReadOnlyList Campaigns { get; set; } = []; - [Parameter] public string CampaignSelectId { get; set; } = "header-campaign-select"; + [Parameter] + public Guid? SelectedCampaignId { get; set; } - [Parameter] public EventCallback CampaignSelectionChanged { get; set; } + [Parameter] + public string CampaignSelectId { get; set; } = "header-campaign-select"; - [Parameter] public bool ShowConnectionState { get; set; } = true; + [Parameter] + public EventCallback CampaignSelectionChanged { get; set; } - [Parameter] public string ConnectionStateLabel { get; set; } = "Offline fallback"; + [Parameter] + public bool ShowConnectionState { get; set; } = true; - [Parameter] public string ConnectionStateCssClass { get; set; } = "offline"; + [Parameter] + public string ConnectionStateLabel { get; set; } = "Offline fallback"; - [Parameter] public bool IsMenuOpen { get; set; } + [Parameter] + public string ConnectionStateCssClass { get; set; } = "offline"; - [Parameter] public string MenuButtonId { get; set; } = "screen-menu-button"; + [Parameter] + public bool IsMenuOpen { get; set; } - [Parameter] public string MenuId { get; set; } = "screen-menu"; + [Parameter] + public string MenuButtonId { get; set; } = "screen-menu-button"; - [Parameter] public IReadOnlyList MenuItems { get; set; } = []; + [Parameter] + public string MenuId { get; set; } = "screen-menu"; - [Parameter] public EventCallback ToggleMenuRequested { get; set; } + [Parameter] + public IReadOnlyList MenuItems { get; set; } = []; - [Parameter] public EventCallback LogoutRequested { get; set; } + [Parameter] + public EventCallback ToggleMenuRequested { get; set; } + + [Parameter] + public string Theme { get; set; } = "light"; + + [Parameter] + public string ThemeToggleLabel { get; set; } = "☀️"; + + [Parameter] + public EventCallback ThemeToggleRequested { get; set; } + + [Parameter] + public EventCallback LogoutRequested { get; set; } } public sealed class AppHeaderMenuItem @@ -50,4 +77,4 @@ public sealed class AppHeaderMenuItem public string Label { get; init; } = string.Empty; public bool IsActive { get; init; } public Func? OnSelected { get; init; } -} +} \ No newline at end of file diff --git a/RpgRoller/Components/Pages/Workspace.razor b/RpgRoller/Components/Pages/Workspace.razor index 341f82f..0402f1b 100644 --- a/RpgRoller/Components/Pages/Workspace.razor +++ b/RpgRoller/Components/Pages/Workspace.razor @@ -28,6 +28,9 @@ MenuId="workspace-screen-menu" MenuItems="HeaderMenuItems" ToggleMenuRequested="ToggleScreenMenu" + Theme="@State.ThemePreference" + ThemeToggleLabel="@State.ThemeToggleLabel" + ThemeToggleRequested="Session.ToggleThemePreferenceAsync" LogoutRequested="Session.LogoutAsync"/> @if (ChildContent is not null) diff --git a/RpgRoller/Components/Pages/WorkspaceSessionCoordinator.cs b/RpgRoller/Components/Pages/WorkspaceSessionCoordinator.cs index 80273d1..40f728a 100644 --- a/RpgRoller/Components/Pages/WorkspaceSessionCoordinator.cs +++ b/RpgRoller/Components/Pages/WorkspaceSessionCoordinator.cs @@ -1,25 +1,10 @@ -using Microsoft.JSInterop; +using Microsoft.JSInterop; using RpgRoller.Contracts; +using RpgRoller.Domain; namespace RpgRoller.Components.Pages; -public sealed class WorkspaceSessionCoordinator( - WorkspaceState state, - WorkspaceFeedbackService feedback, - IJSRuntime js, - RpgRollerApiClient apiClient, - WorkspaceQueryService workspaceQuery, - Func isAdminRoute, - Func redirectToPlayAsync, - Func reloadCampaignsAsync, - Func reloadCharacterCampaignOptionsAsync, - Func refreshCampaignScopeAsync, - Func requestRefreshAsync, - Func syncStateEventsAsync, - Func stopStateEventsAsync, - Func ensureAdminUsersLoadedAsync, - Action resetCampaignLogDetailState, - Func onLoggedOutAsync) +public sealed class WorkspaceSessionCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, IJSRuntime js, RpgRollerApiClient apiClient, WorkspaceQueryService workspaceQuery, Func isAdminRoute, Func redirectToPlayAsync, Func reloadCampaignsAsync, Func reloadCharacterCampaignOptionsAsync, Func refreshCampaignScopeAsync, Func requestRefreshAsync, Func syncStateEventsAsync, Func stopStateEventsAsync, Func ensureAdminUsersLoadedAsync, Action resetCampaignLogDetailState, Func onLoggedOutAsync) { public async Task InitializeAsync() { @@ -27,8 +12,7 @@ public sealed class WorkspaceSessionCoordinator( if (string.Equals(storedPanel, "log", StringComparison.OrdinalIgnoreCase)) state.MobilePanel = "log"; - var storedRollVisibility = - await js.InvokeAsync("rpgRollerApi.getSessionValue", RollVisibilitySessionKey); + var storedRollVisibility = await js.InvokeAsync("rpgRollerApi.getSessionValue", RollVisibilitySessionKey); state.RollVisibility = NormalizeRollVisibility(storedRollVisibility); Guid? preferredCampaignId = null; @@ -101,6 +85,33 @@ public sealed class WorkspaceSessionCoordinator( await requestRefreshAsync(); } + public async Task ToggleThemePreferenceAsync() + { + if (state.User is null || state.IsMutating) + return; + + var previousTheme = state.ThemePreference; + var nextTheme = state.NextThemePreference; + state.ThemePreference = nextTheme; + await js.InvokeVoidAsync("rpgRollerApi.applyTheme", nextTheme); + await requestRefreshAsync(); + + try + { + state.User = await apiClient.RequestAsync("PUT", "/api/me/theme", new UpdateThemePreferenceRequest(nextTheme)); + state.ThemePreference = NormalizeThemePreference(state.User.ThemePreference); + await js.InvokeVoidAsync("rpgRollerApi.applyTheme", state.ThemePreference); + } + catch (ApiRequestException ex) + { + state.ThemePreference = previousTheme; + await js.InvokeVoidAsync("rpgRollerApi.applyTheme", previousTheme); + feedback.SetStatus(ex.Message, true); + } + + await requestRefreshAsync(); + } + public void ClearAuthenticatedState() { state.User = null; @@ -117,6 +128,7 @@ public sealed class WorkspaceSessionCoordinator( state.SelectedCharacterId = null; state.LastRoll = null; state.KnownUsernames = []; + state.ThemePreference = ThemePreferences.Light; state.ShowCreateCharacterModal = false; state.ShowEditCharacterModal = false; state.CanEditCharacterOwner = false; @@ -161,6 +173,7 @@ public sealed class WorkspaceSessionCoordinator( state.User = me.User; state.ActiveCharacterId = me.ActiveCharacterId; + await EnsureThemePreferenceAsync(); if (!await EnsureRouteAccessAsync()) return true; @@ -211,6 +224,38 @@ public sealed class WorkspaceSessionCoordinator( return string.Equals(visibility, "private", StringComparison.OrdinalIgnoreCase) ? "private" : "public"; } + private async Task EnsureThemePreferenceAsync() + { + if (state.User is null) + return; + + var themePreference = state.User.ThemePreference; + if (ThemePreferences.IsSupported(themePreference)) + { + state.ThemePreference = ThemePreferences.Normalize(themePreference!); + await js.InvokeVoidAsync("rpgRollerApi.applyTheme", state.ThemePreference); + return; + } + + var systemThemePreference = await js.InvokeAsync("rpgRollerApi.getSystemTheme"); + state.ThemePreference = NormalizeThemePreference(systemThemePreference); + await js.InvokeVoidAsync("rpgRollerApi.applyTheme", state.ThemePreference); + + try + { + state.User = await apiClient.RequestAsync("PUT", "/api/me/theme", new UpdateThemePreferenceRequest(state.ThemePreference)); + } + catch (ApiRequestException ex) + { + feedback.SetStatus(ex.Message, true); + } + } + + private static string NormalizeThemePreference(string? themePreference) + { + return ThemePreferences.IsSupported(themePreference) ? ThemePreferences.Normalize(themePreference!) : ThemePreferences.Light; + } + private const string CampaignSessionKey = "campaign"; private const string MobilePanelSessionKey = "play-panel"; private const string RollVisibilitySessionKey = "roll-visibility"; diff --git a/RpgRoller/Components/Pages/WorkspaceState.cs b/RpgRoller/Components/Pages/WorkspaceState.cs index 21bb79d..a929b0f 100644 --- a/RpgRoller/Components/Pages/WorkspaceState.cs +++ b/RpgRoller/Components/Pages/WorkspaceState.cs @@ -17,9 +17,7 @@ public sealed class WorkspaceState if (ownerUserId == SelectedCampaign.Gm.Id) return $"{SelectedCampaign.Gm.DisplayName} (GM)"; - var ownerDisplayName = SelectedCampaign.Characters.Where(character => character.OwnerUserId == ownerUserId) - .Select(character => character.OwnerDisplayName) - .FirstOrDefault(displayName => !string.IsNullOrWhiteSpace(displayName)); + var ownerDisplayName = SelectedCampaign.Characters.Where(character => character.OwnerUserId == ownerUserId).Select(character => character.OwnerDisplayName).FirstOrDefault(displayName => !string.IsNullOrWhiteSpace(displayName)); return string.IsNullOrWhiteSpace(ownerDisplayName) ? "Unknown owner" : ownerDisplayName; } @@ -28,10 +26,8 @@ public sealed class WorkspaceState { if (!string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase)) { - if (string.Equals(SelectedCampaign?.RulesetId, RulesetFormHelpers.RulesetIds.Rolemaster, - StringComparison.OrdinalIgnoreCase)) - return RulesetFormHelpers.DescribeRolemasterExpression(skill.DiceRollDefinition, skill.FumbleRange, - skill.RolemasterAutoRetry); + if (string.Equals(SelectedCampaign?.RulesetId, RulesetFormHelpers.RulesetIds.Rolemaster, StringComparison.OrdinalIgnoreCase)) + return RulesetFormHelpers.DescribeRolemasterExpression(skill.DiceRollDefinition, skill.FumbleRange, skill.RolemasterAutoRetry); return skill.DiceRollDefinition; } @@ -55,6 +51,7 @@ public sealed class WorkspaceState public RollResult? LastRoll { get; set; } public List KnownUsernames { get; set; } = []; public string RollVisibility { get; set; } = "public"; + public string ThemePreference { get; set; } = ThemePreferences.Light; public bool IsMutating { get; set; } public bool IsCampaignDataLoading { get; set; } @@ -102,17 +99,14 @@ public sealed class WorkspaceState return null; if (User is null) - return new(SelectedCampaign.Id, SelectedCampaign.Name, SelectedCampaign.RulesetId, SelectedCampaign.Gm, - []); + return new(SelectedCampaign.Id, SelectedCampaign.Name, SelectedCampaign.RulesetId, SelectedCampaign.Gm, []); if (IsCurrentUserGm) return SelectedCampaign; - var ownedCharacters = SelectedCampaign.Characters.Where(character => character.OwnerUserId == User.Id) - .ToArray(); + var ownedCharacters = SelectedCampaign.Characters.Where(character => character.OwnerUserId == User.Id).ToArray(); - return new(SelectedCampaign.Id, SelectedCampaign.Name, SelectedCampaign.RulesetId, SelectedCampaign.Gm, - ownedCharacters); + return new(SelectedCampaign.Id, SelectedCampaign.Name, SelectedCampaign.RulesetId, SelectedCampaign.Gm, ownedCharacters); } } @@ -126,18 +120,14 @@ public sealed class WorkspaceState if (SelectedCharacterId.HasValue) { - var selectedCharacter = - playSelectedCampaign.Characters.FirstOrDefault(character => - character.Id == SelectedCharacterId.Value); + var selectedCharacter = playSelectedCampaign.Characters.FirstOrDefault(character => character.Id == SelectedCharacterId.Value); if (selectedCharacter is not null) return selectedCharacter; } if (ActiveCharacterId.HasValue) { - var activeCharacter = - playSelectedCampaign.Characters.FirstOrDefault(character => - character.Id == ActiveCharacterId.Value); + var activeCharacter = playSelectedCampaign.Characters.FirstOrDefault(character => character.Id == ActiveCharacterId.Value); if (activeCharacter is not null) return activeCharacter; } @@ -170,15 +160,20 @@ public sealed class WorkspaceState public string ConnectionStateLabel => ConnectionState switch { - "connected" => "Connected", + "connected" => "Connected", "reconnecting" => "Reconnecting", - _ => "Offline fallback" + _ => "Offline fallback" }; public string ConnectionStateCssClass => ConnectionState switch { - "connected" => "ok", + "connected" => "ok", "reconnecting" => "warn", - _ => "offline" + _ => "offline" }; -} + + public string ThemeToggleLabel => ThemePreference == ThemePreferences.Dark ? "⏾" : "☀️"; + + public string NextThemePreference => + ThemePreference == ThemePreferences.Dark ? ThemePreferences.Light : ThemePreferences.Dark; +} \ No newline at end of file diff --git a/RpgRoller/Contracts/ApiContracts.cs b/RpgRoller/Contracts/ApiContracts.cs index eeea9ea..481e594 100644 --- a/RpgRoller/Contracts/ApiContracts.cs +++ b/RpgRoller/Contracts/ApiContracts.cs @@ -1,4 +1,4 @@ -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; namespace RpgRoller.Contracts; @@ -10,10 +10,12 @@ public sealed record RegisterRequest(string Username, string Password, string Di public sealed record LoginRequest(string Username, string Password); -public sealed record UserSummary(Guid Id, string Username, string DisplayName, IReadOnlyList Roles); +public sealed record UserSummary(Guid Id, string Username, string DisplayName, IReadOnlyList Roles, string? ThemePreference = null); public sealed record MeResponse(UserSummary User, Guid? ActiveCharacterId, Guid? CurrentCampaignId); +public sealed record UpdateThemePreferenceRequest(string ThemePreference); + public sealed record AdminUserSummary(Guid Id, string Username, string DisplayName, IReadOnlyList Roles); public sealed record UpdateUserRolesRequest(IReadOnlyList Roles); diff --git a/RpgRoller/Contracts/RpgRollerJsonSerializerContext.cs b/RpgRoller/Contracts/RpgRollerJsonSerializerContext.cs index e55949e..51c10ba 100644 --- a/RpgRoller/Contracts/RpgRollerJsonSerializerContext.cs +++ b/RpgRoller/Contracts/RpgRollerJsonSerializerContext.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using System.Text.Json.Serialization; namespace RpgRoller.Contracts; @@ -52,6 +52,7 @@ namespace RpgRoller.Contracts; [JsonSerializable(typeof(UpdateCharacterRequest))] [JsonSerializable(typeof(UpdateSkillGroupRequest))] [JsonSerializable(typeof(UpdateSkillRequest))] +[JsonSerializable(typeof(UpdateThemePreferenceRequest))] [JsonSerializable(typeof(UpdateUserRolesRequest))] [JsonSerializable(typeof(UserSummary))] public partial class RpgRollerJsonSerializerContext : JsonSerializerContext diff --git a/RpgRoller/Data/RpgRollerDbContext.cs b/RpgRoller/Data/RpgRollerDbContext.cs index 0181750..dcc4b39 100644 --- a/RpgRoller/Data/RpgRollerDbContext.cs +++ b/RpgRoller/Data/RpgRollerDbContext.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; using RpgRoller.Domain; namespace RpgRoller.Data; @@ -15,6 +15,7 @@ public sealed class RpgRollerDbContext(DbContextOptions opti entity.Property(x => x.PasswordHash).IsRequired(); entity.Property(x => x.DisplayName).IsRequired().HasMaxLength(128); entity.Property(x => x.Roles).IsRequired().HasMaxLength(256); + entity.Property(x => x.ThemePreference).IsRequired(false).HasMaxLength(16); entity.HasIndex(x => x.UsernameNormalized).IsUnique(); }); diff --git a/RpgRoller/Domain/GameModels.cs b/RpgRoller/Domain/GameModels.cs index dbaf3a1..de7953a 100644 --- a/RpgRoller/Domain/GameModels.cs +++ b/RpgRoller/Domain/GameModels.cs @@ -1,4 +1,4 @@ -namespace RpgRoller.Domain; +namespace RpgRoller.Domain; public enum RulesetKind { @@ -22,6 +22,7 @@ public sealed class UserAccount public required string DisplayName { get; set; } public required string Roles { get; set; } public Guid? ActiveCharacterId { get; set; } + public string? ThemePreference { get; set; } } public static class UserRoles diff --git a/RpgRoller/Domain/ThemePreferences.cs b/RpgRoller/Domain/ThemePreferences.cs new file mode 100644 index 0000000..6ad6685 --- /dev/null +++ b/RpgRoller/Domain/ThemePreferences.cs @@ -0,0 +1,17 @@ +namespace RpgRoller.Domain; + +public static class ThemePreferences +{ + public const string Light = "light"; + public const string Dark = "dark"; + + public static bool IsSupported(string? value) + { + return string.Equals(value, Light, StringComparison.OrdinalIgnoreCase) || string.Equals(value, Dark, StringComparison.OrdinalIgnoreCase); + } + + public static string Normalize(string value) + { + return string.Equals(value, Dark, StringComparison.OrdinalIgnoreCase) ? Dark : Light; + } +} \ No newline at end of file diff --git a/RpgRoller/Migrations/20260518183838_AddUserThemePreference.Designer.cs b/RpgRoller/Migrations/20260518183838_AddUserThemePreference.Designer.cs new file mode 100644 index 0000000..70f4cc9 --- /dev/null +++ b/RpgRoller/Migrations/20260518183838_AddUserThemePreference.Designer.cs @@ -0,0 +1,273 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using RpgRoller.Data; + +#nullable disable + +namespace RpgRoller.Migrations +{ + [DbContext(typeof(RpgRollerDbContext))] + [Migration("20260518183838_AddUserThemePreference")] + partial class AddUserThemePreference + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.2"); + + modelBuilder.Entity("RpgRoller.Domain.Campaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GmUserId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Ruleset") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GmUserId"); + + b.ToTable("Campaigns"); + }); + + modelBuilder.Entity("RpgRoller.Domain.Character", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CampaignId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Characters"); + }); + + modelBuilder.Entity("RpgRoller.Domain.RollLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Breakdown") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("CampaignId") + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("Dice") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Result") + .HasColumnType("INTEGER"); + + b.Property("RollerUserId") + .HasColumnType("TEXT"); + + b.Property("SkillId") + .HasColumnType("TEXT"); + + b.Property("TimestampUtc") + .HasColumnType("TEXT"); + + b.Property("Visibility") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("CharacterId"); + + b.HasIndex("RollerUserId"); + + b.HasIndex("SkillId"); + + b.ToTable("RollLogEntries"); + }); + + modelBuilder.Entity("RpgRoller.Domain.Skill", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AllowFumble") + .HasColumnType("INTEGER"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("DiceRollDefinition") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("FumbleRange") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("RolemasterAutoRetry") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("SkillGroupId") + .HasColumnType("TEXT"); + + b.Property("WildDice") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CharacterId"); + + b.HasIndex("SkillGroupId"); + + b.ToTable("Skills"); + }); + + modelBuilder.Entity("RpgRoller.Domain.SkillGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AllowFumble") + .HasColumnType("INTEGER"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("DiceRollDefinition") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("FumbleRange") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("WildDice") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CharacterId"); + + b.ToTable("SkillGroups"); + }); + + modelBuilder.Entity("RpgRoller.Domain.UserAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ActiveCharacterId") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Roles") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ThemePreference") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UsernameNormalized") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UsernameNormalized") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("RpgRoller.Domain.UserSession", b => + { + b.Property("Token") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Token"); + + b.HasIndex("UserId"); + + b.ToTable("Sessions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/RpgRoller/Migrations/20260518183838_AddUserThemePreference.cs b/RpgRoller/Migrations/20260518183838_AddUserThemePreference.cs new file mode 100644 index 0000000..1822bc9 --- /dev/null +++ b/RpgRoller/Migrations/20260518183838_AddUserThemePreference.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RpgRoller.Migrations +{ + /// + public partial class AddUserThemePreference : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn(name: "ThemePreference", table: "Users", type: "TEXT", maxLength: 16, nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "ThemePreference", table: "Users"); + } + } +} \ No newline at end of file diff --git a/RpgRoller/Migrations/RpgRollerDbContextModelSnapshot.cs b/RpgRoller/Migrations/RpgRollerDbContextModelSnapshot.cs index a987707..1807c0d 100644 --- a/RpgRoller/Migrations/RpgRollerDbContextModelSnapshot.cs +++ b/RpgRoller/Migrations/RpgRollerDbContextModelSnapshot.cs @@ -224,6 +224,10 @@ namespace RpgRoller.Migrations .HasMaxLength(256) .HasColumnType("TEXT"); + b.Property("ThemePreference") + .HasMaxLength(16) + .HasColumnType("TEXT"); + b.Property("Username") .IsRequired() .HasMaxLength(64) diff --git a/RpgRoller/Services/GameAuthService.cs b/RpgRoller/Services/GameAuthService.cs index 0474bec..724f806 100644 --- a/RpgRoller/Services/GameAuthService.cs +++ b/RpgRoller/Services/GameAuthService.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity; using RpgRoller.Contracts; using RpgRoller.Domain; @@ -32,7 +32,8 @@ public sealed class GameAuthService(GameStateStore stateStore, IPasswordHasher UpdateThemePreference(string sessionToken, string themePreference) + { + lock (stateStore.Gate) + { + var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken); + if (user is null) + return ServiceResult.Failure("unauthorized", "You must be logged in."); + + if (!ThemePreferences.IsSupported(themePreference)) + return ServiceResult.Failure("invalid_theme_preference", "Theme preference must be light or dark."); + + user.ThemePreference = ThemePreferences.Normalize(themePreference); + persistenceService.PersistStateLocked(); + return ServiceResult.Success(GameDtoMapper.ToUserSummary(user)); + } + } + private UserSession CreateSession(Guid userId) { var token = Guid.NewGuid().ToString("N"); diff --git a/RpgRoller/Services/GameDtoMapper.cs b/RpgRoller/Services/GameDtoMapper.cs index d2e8c14..72e4d99 100644 --- a/RpgRoller/Services/GameDtoMapper.cs +++ b/RpgRoller/Services/GameDtoMapper.cs @@ -1,4 +1,4 @@ -using RpgRoller.Contracts; +using RpgRoller.Contracts; using RpgRoller.Domain; namespace RpgRoller.Services; @@ -7,7 +7,7 @@ public static class GameDtoMapper { public static UserSummary ToUserSummary(UserAccount user) { - return new(user.Id, user.Username, user.DisplayName, RoleSerializer.Parse(user.Roles)); + return new(user.Id, user.Username, user.DisplayName, RoleSerializer.Parse(user.Roles), user.ThemePreference); } public static AdminUserSummary ToAdminUserSummary(UserAccount user) diff --git a/RpgRoller/Services/GamePersistenceService.cs b/RpgRoller/Services/GamePersistenceService.cs index ae33d8b..e330e73 100644 --- a/RpgRoller/Services/GamePersistenceService.cs +++ b/RpgRoller/Services/GamePersistenceService.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; using RpgRoller.Data; using RpgRoller.Domain; @@ -40,7 +40,8 @@ public sealed class GamePersistenceService(IDbContextFactory PasswordHash = user.PasswordHash, DisplayName = user.DisplayName, Roles = string.IsNullOrWhiteSpace(user.Roles) ? string.Empty : RoleSerializer.Serialize(RoleSerializer.Parse(user.Roles)), - ActiveCharacterId = user.ActiveCharacterId + ActiveCharacterId = user.ActiveCharacterId, + ThemePreference = string.IsNullOrWhiteSpace(user.ThemePreference) ? null : ThemePreferences.Normalize(user.ThemePreference) }; stateStore.UsersById[storedUser.Id] = storedUser; stateStore.UserIdsByUsername[normalizedUsername] = storedUser.Id; diff --git a/RpgRoller/Services/GameService.cs b/RpgRoller/Services/GameService.cs index a78f4ab..8aa46e6 100644 --- a/RpgRoller/Services/GameService.cs +++ b/RpgRoller/Services/GameService.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using RpgRoller.Contracts; using RpgRoller.Data; @@ -55,6 +55,11 @@ public sealed class GameService : IGameService return m_AuthService.GetMe(sessionToken); } + public ServiceResult UpdateThemePreference(string sessionToken, string themePreference) + { + return m_AuthService.UpdateThemePreference(sessionToken, themePreference); + } + public ServiceResult CreateCampaign(string sessionToken, string name, string rulesetId) { return m_CampaignService.CreateCampaign(sessionToken, name, rulesetId); diff --git a/RpgRoller/Services/GameStateCloneFactory.cs b/RpgRoller/Services/GameStateCloneFactory.cs index 1146d55..76d76cf 100644 --- a/RpgRoller/Services/GameStateCloneFactory.cs +++ b/RpgRoller/Services/GameStateCloneFactory.cs @@ -1,4 +1,4 @@ -using RpgRoller.Domain; +using RpgRoller.Domain; namespace RpgRoller.Services; @@ -14,7 +14,8 @@ public static class GameStateCloneFactory PasswordHash = user.PasswordHash, DisplayName = user.DisplayName, Roles = user.Roles, - ActiveCharacterId = user.ActiveCharacterId + ActiveCharacterId = user.ActiveCharacterId, + ThemePreference = user.ThemePreference }; } diff --git a/RpgRoller/Services/IGameService.cs b/RpgRoller/Services/IGameService.cs index 25df79b..b71788e 100644 --- a/RpgRoller/Services/IGameService.cs +++ b/RpgRoller/Services/IGameService.cs @@ -1,4 +1,4 @@ -using RpgRoller.Contracts; +using RpgRoller.Contracts; namespace RpgRoller.Services; @@ -11,6 +11,7 @@ public interface IGameService void Logout(string sessionToken); UserSummary? GetUserBySession(string sessionToken); ServiceResult GetMe(string sessionToken); + ServiceResult UpdateThemePreference(string sessionToken, string themePreference); ServiceResult CreateCampaign(string sessionToken, string name, string rulesetId); ServiceResult> GetCampaigns(string sessionToken); diff --git a/RpgRoller/wwwroot/images/dark.png b/RpgRoller/wwwroot/images/dark.png new file mode 100644 index 0000000..3da2857 Binary files /dev/null and b/RpgRoller/wwwroot/images/dark.png differ diff --git a/RpgRoller/wwwroot/images/rpg.png b/RpgRoller/wwwroot/images/light.png similarity index 100% rename from RpgRoller/wwwroot/images/rpg.png rename to RpgRoller/wwwroot/images/light.png diff --git a/RpgRoller/wwwroot/js/rpgroller-api.js b/RpgRoller/wwwroot/js/rpgroller-api.js index 994a8dc..b576bfb 100644 --- a/RpgRoller/wwwroot/js/rpgroller-api.js +++ b/RpgRoller/wwwroot/js/rpgroller-api.js @@ -1,4 +1,4 @@ -window.rpgRollerApi = (() => { +window.rpgRollerApi = (() => { const sessionPrefix = "rpgroller."; const stateStream = { source: null, @@ -22,6 +22,18 @@ window.rpgRollerApi = (() => { return new URL(relativeUrl, document.baseURI).toString(); } + function normalizeTheme(theme) { + return theme === "dark" ? "dark" : "light"; + } + + function getSystemTheme() { + return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + } + + function applyTheme(theme) { + document.documentElement.dataset.theme = normalizeTheme(theme); + } + function clearReconnectTimer() { if (stateStream.reconnectTimer) { clearTimeout(stateStream.reconnectTimer); @@ -385,6 +397,8 @@ window.rpgRollerApi = (() => { return { request, + applyTheme, + getSystemTheme, getSessionValue, setSessionValue, startStateEvents, diff --git a/RpgRoller/wwwroot/styles.css b/RpgRoller/wwwroot/styles.css index 7207763..08b2fd1 100644 --- a/RpgRoller/wwwroot/styles.css +++ b/RpgRoller/wwwroot/styles.css @@ -1,4 +1,5 @@ :root { + color-scheme: light; --bg-top: #f7f0d8; --bg-bottom: #ecdfc4; --button-hover: #dccfb4; @@ -14,6 +15,147 @@ --public: #2d6645; --private-self: #4f3a8f; --private-gm: #915119; + --page-background: url("/images/light.png"); + --card-strong: #fff8ea; + --accent-dark: #2f4f34; + --accent-2-hover: #6b2419; + --header-bg: linear-gradient(120deg, #f1e4c9, #efe0bf); + --input-bg: #fffdf5; + --input-border: #8e7b57; + --button-text: #f8f7ef; + --switch-active-text: #fff9ef; + --tab-active-bg: linear-gradient(145deg, #e9d4a4, #d7b672); + --tab-active-border: #9e7328; + --section-border: #a89066; + --skill-group-bg: #f8f0de; + --chip-border: #decbb7; + --menu-shadow: rgba(34, 24, 9, 0.2); + --die-border: #2a2418; + --die-bg: #ffffff; + --die-text: #1f1a13; + --die-wild: #c79913; + --die-crit-bg: #d8ffc2; + --die-crit-text: #18490f; + --die-fumble-bg: #ffb5a8; + --die-fumble-text: #661110; + --die-added-bg: #dbffdf; + --die-added-text: #206029; + --die-removed-bg: #fde0dd; + --die-removed-text: #7f5f55; + --die-neutral-bg: #f8f1df; + --die-neutral-text: #3f2f12; + --die-open-high-bg: #dff6df; + --die-open-high-text: #1d5b26; + --die-open-high-border: #2a7c39; + --die-open-low-bg: #ffe1dc; + --die-open-low-text: #8a2217; + --die-open-low-border: #b74334; + --success-bg: #e8f7e8; + --success-border: #78a978; + --success-text: #1f5425; + --error-bg: #ffe9e5; + --error-border: #bb6e62; + --error-text: #7f2015; + --rare-bg: #fff1c7; + --rare-border: #b48b34; + --rare-text: #6d4c05; + --active-bg: #f6d28d; + --active-border: #8f5f12; + --active-text: #5d3808; + --skeleton-bg: linear-gradient(90deg, #dfd2b7, #efe3c9, #dfd2b7); + --health-bg: #fff2db; + --health-border: #b77a29; + --modal-overlay: rgba(35, 25, 9, 0.55); + --mobile-nav-bg: rgba(241, 228, 201, 0.96); + --toast-shadow: rgba(34, 24, 9, 0.22); + --surface-mix: #ffffff; + --transparent-mix: transparent; + --custom-roll-error-bg: #fff0ee; + --custom-roll-error-border: #6b2015; + --custom-roll-error-shadow: rgba(181, 58, 35, 0.12); + --entry-shadow: rgba(60, 41, 12, 0.07); + --entry-shadow-hover: rgba(60, 41, 12, 0.11); + --fresh-shadow: rgba(199, 153, 19, 0.16); +} + +:root[data-theme="dark"] { + color-scheme: dark; + --bg-top: #060b13; + --bg-bottom: #0c1726; + --button-hover: rgba(62, 89, 123, 0.72); + --card: rgba(8, 15, 26, 0.84); + --card-border: #37516c; + --text: #edf5ff; + --muted: #adc1d6; + --accent: #5aa0cf; + --accent-2: #f0b35a; + --warn: #f4c35f; + --danger: #ff8b7a; + --focus: #91d5ff; + --public: #78d08f; + --private-self: #b9a0ff; + --private-gm: #f0b16c; + --page-background: url("/images/dark.png"); + --card-strong: rgba(13, 24, 39, 0.96); + --accent-dark: #2d638f; + --accent-2-hover: #ffd085; + --header-bg: linear-gradient(120deg, rgba(10, 18, 31, 0.94), rgba(17, 31, 48, 0.9)); + --input-bg: rgba(7, 14, 24, 0.92); + --input-border: #52708f; + --button-text: #f3f8ff; + --switch-active-text: #0b1420; + --tab-active-bg: linear-gradient(145deg, #244967, #17324d); + --tab-active-border: #6ca6d0; + --section-border: #486986; + --skill-group-bg: rgba(19, 34, 52, 0.72); + --chip-border: #415f7b; + --menu-shadow: rgba(0, 0, 0, 0.42); + --die-border: #9cb8d3; + --die-bg: #0f1c2d; + --die-text: #edf5ff; + --die-wild: #ffd770; + --die-crit-bg: #163f2a; + --die-crit-text: #a5f0b5; + --die-fumble-bg: #4b1c20; + --die-fumble-text: #ffc0b8; + --die-added-bg: #163f2a; + --die-added-text: #a5f0b5; + --die-removed-bg: #3b2630; + --die-removed-text: #e0abb7; + --die-neutral-bg: #14283e; + --die-neutral-text: #e5f1ff; + --die-open-high-bg: #133b2b; + --die-open-high-text: #a5f0b5; + --die-open-high-border: #64c783; + --die-open-low-bg: #482025; + --die-open-low-text: #ffc0b8; + --die-open-low-border: #ff8b7a; + --success-bg: #173b29; + --success-border: #66bd7f; + --success-text: #b6f1c3; + --error-bg: #452126; + --error-border: #d46b62; + --error-text: #ffc2ba; + --rare-bg: #3d3218; + --rare-border: #cfae52; + --rare-text: #ffe09a; + --active-bg: #4a3514; + --active-border: #e0b35d; + --active-text: #ffe1a3; + --skeleton-bg: linear-gradient(90deg, #172438, #263b54, #172438); + --health-bg: #3a2d19; + --health-border: #d09b4c; + --modal-overlay: rgba(1, 6, 13, 0.74); + --mobile-nav-bg: rgba(10, 18, 31, 0.96); + --toast-shadow: rgba(0, 0, 0, 0.42); + --surface-mix: #000000; + --transparent-mix: transparent; + --custom-roll-error-bg: #371c21; + --custom-roll-error-border: #ffc0b8; + --custom-roll-error-shadow: rgba(255, 139, 122, 0.18); + --entry-shadow: rgba(0, 0, 0, 0.24); + --entry-shadow-hover: rgba(0, 0, 0, 0.34); + --fresh-shadow: rgba(255, 215, 112, 0.2); } * { @@ -28,7 +170,7 @@ body { } html { - background-image: url("/images/rpg.png"); + background-image: var(--page-background); background-position: center; background-repeat: no-repeat; background-size: cover; @@ -100,7 +242,7 @@ h3 { top: 0; z-index: 10; display: flex; - background: linear-gradient(120deg, #f1e4c9, #efe0bf); + background: var(--header-bg); border: 1px solid var(--card-border); border-radius: 0.8rem; padding: 0.5rem 0.7rem; @@ -209,19 +351,19 @@ select, button { font: inherit; border-radius: 0.45rem; - border: 1px solid #8e7b57; + border: 1px solid var(--input-border); padding: 0.55rem 0.65rem; } input, select { - background: #fffdf5; + background: var(--input-bg); color: var(--text); } button { - background: linear-gradient(180deg, var(--accent), #2f4f34); - color: #f8f7ef; + background: linear-gradient(180deg, var(--accent), var(--accent-dark)); + color: var(--button-text); border-color: transparent; cursor: pointer; } @@ -229,19 +371,19 @@ button { button.ghost { background: transparent; color: var(--text); - border-color: #8e7b57; + border-color: var(--input-border); } button.switch { background: transparent; color: var(--text); - border-color: #8e7b57; + border-color: var(--input-border); } button.switch.active { background: var(--accent-2); border-color: var(--accent-2); - color: #fff9ef; + color: var(--switch-active-text); } button:disabled { @@ -327,12 +469,12 @@ select:focus-visible { white-space: nowrap; background: transparent; color: var(--text); - border-color: #8e7b57; + border-color: var(--input-border); } .icon-tab.active { - background: linear-gradient(145deg, #e9d4a4, #d7b672); - border-color: #9e7328; + background: var(--tab-active-bg); + border-color: var(--tab-active-border); } .icon-tab-glyph { @@ -342,7 +484,7 @@ select:focus-visible { align-items: center; justify-content: center; border-radius: 50%; - border: 1px solid #8e7b57; + border: 1px solid var(--input-border); font-weight: 700; font-size: 0.72rem; } @@ -353,7 +495,7 @@ select:focus-visible { } .skills-section { - border: 1px dashed #a89066; + border: 1px dashed var(--section-border); border-radius: 0.65rem; padding: 0.55rem; display: flex; @@ -414,7 +556,7 @@ select:focus-visible { display: flex; align-items: center; justify-content: space-between; - background-color: #f8f0de; + background-color: var(--skill-group-bg); padding: 0.1rem; border-top: 1px solid var(--card-border); gap: 0.5rem; @@ -457,11 +599,11 @@ select:focus-visible { border-radius: 999px; background: transparent; color: var(--text); - border-color: #decbb7; + border-color: var(--chip-border); } .chip-button:hover { - border-color: #8e7b57; + border-color: var(--input-border); background: var(--button-hover); } @@ -469,13 +611,13 @@ select:focus-visible { grid-template-columns: auto 1fr; align-items: center; justify-items: start; - background: #00000000; + background: transparent; } .skill-create-icon { width: 1.45rem; height: 1.45rem; - border: 1px solid #8e7b57; + border: 1px solid var(--input-border); border-radius: 999px; display: inline-flex; align-items: center; @@ -520,7 +662,7 @@ select:focus-visible { .menu-toggle { background: transparent; color: var(--text); - border-color: #8e7b57; + border-color: var(--input-border); display: inline-flex; gap: 0.4rem; align-items: center; @@ -537,12 +679,12 @@ select:focus-visible { z-index: 40; min-width: 14.5rem; padding: 0.35rem; - background: #fff8ea; + background: var(--card-strong); border: 1px solid var(--card-border); border-radius: 0.55rem; display: grid; gap: 0.3rem; - box-shadow: 0 8px 16px rgba(34, 24, 9, 0.2); + box-shadow: 0 8px 16px var(--menu-shadow); } .menu-item { @@ -550,12 +692,12 @@ select:focus-visible { text-align: left; background: transparent; color: var(--text); - border-color: #8e7b57; + border-color: var(--input-border); } .menu-item.active { - background: #ecd8ae; - border-color: #9a7f43; + background: var(--button-hover); + border-color: var(--tab-active-border); } .logout-link { @@ -566,7 +708,7 @@ select:focus-visible { } .logout-link:hover { - color: #6b2419; + color: var(--accent-2-hover); } .logout-link:focus-visible { @@ -574,6 +716,22 @@ select:focus-visible { outline-offset: 2px; } +.theme-toggle { + width: 2.25rem; + height: 2.25rem; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + background: transparent; + color: var(--text); + border-color: var(--input-border); +} + +.theme-toggle:hover { + background: var(--button-hover); +} + .roll-total { font-size: 1.8rem; font-weight: 800; @@ -597,10 +755,10 @@ select:focus-visible { min-width: 2.1rem; height: 2.1rem; padding: 0.2rem 0.45rem 0; - border: 2px solid #2a2418; + border: 2px solid var(--die-border); border-radius: 0.45rem; - background: #ffffff; - color: #1f1a13; + background: var(--die-bg); + color: var(--die-text); font-size: 2rem; line-height: 1; font-variant-numeric: tabular-nums; @@ -608,27 +766,27 @@ select:focus-visible { .die-chip.wild { border-width: 3px; - border-color: #c79913; + border-color: var(--die-wild); } .die-chip.crit { - background: #d8ffc2; - color: #18490f; + background: var(--die-crit-bg); + color: var(--die-crit-text); } .die-chip.fumble { - background: #ffb5a8; - color: #661110; + background: var(--die-fumble-bg); + color: var(--die-fumble-text); } .die-chip.added { - background: #dbffdf; - color: #206029; + background: var(--die-added-bg); + color: var(--die-added-text); } .die-chip.removed { - background: #fde0dd; - color: #7f5f55; + background: var(--die-removed-bg); + color: var(--die-removed-text); border-style: dashed; } @@ -646,20 +804,20 @@ select:focus-visible { .die-chip.rolemaster-initiative, .die-chip.rolemaster-percentile, .die-chip.rolemaster-open-ended-initial { - background: #f8f1df; - color: #3f2f12; + background: var(--die-neutral-bg); + color: var(--die-neutral-text); } .die-chip.rolemaster-open-ended-high { - background: #dff6df; - color: #1d5b26; - border-color: #2a7c39; + background: var(--die-open-high-bg); + color: var(--die-open-high-text); + border-color: var(--die-open-high-border); } .die-chip.rolemaster-open-ended-low-subtract { - background: #ffe1dc; - color: #8a2217; - border-color: #b74334; + background: var(--die-open-low-bg); + color: var(--die-open-low-text); + border-color: var(--die-open-low-border); } .empty, @@ -676,11 +834,11 @@ select:focus-visible { } .custom-roll-panel { - border-top: 1px solid color-mix(in srgb, var(--card-border) 72%, #ffffff 28%); - background: color-mix(in srgb, var(--card) 88%, #ffffff 12%); + border-top: 1px solid color-mix(in srgb, var(--card-border) 72%, var(--surface-mix) 28%); + background: color-mix(in srgb, var(--card) 88%, var(--surface-mix) 12%); border-radius: 0.95rem; padding: 0.85rem 0.9rem 0.9rem; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.45); + box-shadow: inset 0 1px 0 color-mix(in srgb, var(--surface-mix) 45%, transparent 55%); } .custom-roll-composer { @@ -712,14 +870,14 @@ select:focus-visible { min-width: 0; padding: 0.72rem 0.9rem; border-radius: 999px; - border: 1px solid color-mix(in srgb, var(--card-border) 78%, #ffffff 22%); - background: color-mix(in srgb, var(--card) 90%, #ffffff 10%); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6); + border: 1px solid color-mix(in srgb, var(--card-border) 78%, var(--surface-mix) 22%); + background: color-mix(in srgb, var(--card) 90%, var(--surface-mix) 10%); + box-shadow: inset 0 1px 0 color-mix(in srgb, var(--surface-mix) 60%, transparent 40%); transition: border-color 180ms ease, box-shadow 180ms ease, background-color 180ms ease; } .custom-roll-input::placeholder { - color: color-mix(in srgb, var(--muted) 80%, #ffffff 20%); + color: color-mix(in srgb, var(--muted) 80%, var(--surface-mix) 20%); } .custom-roll-input:hover:not(:disabled) { @@ -727,9 +885,9 @@ select:focus-visible { } .custom-roll-input.error { - border-color: color-mix(in srgb, var(--danger) 74%, #6b2015 26%); - background: color-mix(in srgb, #fff0ee 84%, var(--card) 16%); - box-shadow: 0 0 0 3px rgba(181, 58, 35, 0.12); + border-color: color-mix(in srgb, var(--danger) 74%, var(--custom-roll-error-border) 26%); + background: color-mix(in srgb, var(--custom-roll-error-bg) 84%, var(--card) 16%); + box-shadow: 0 0 0 3px var(--custom-roll-error-shadow); } .custom-roll-composer-row button { @@ -739,11 +897,11 @@ select:focus-visible { } .log-entry { - border: 1px solid color-mix(in srgb, var(--card-border) 84%, #ffffff 16%); + border: 1px solid color-mix(in srgb, var(--card-border) 84%, var(--surface-mix) 16%); border-radius: 0.85rem; - background: color-mix(in srgb, var(--card) 96%, #ffffff 4%); + background: color-mix(in srgb, var(--card) 96%, var(--surface-mix) 4%); overflow: hidden; - box-shadow: 0 0.45rem 1.2rem rgba(60, 41, 12, 0.07); + box-shadow: 0 0.45rem 1.2rem var(--entry-shadow); transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease; } @@ -762,23 +920,23 @@ select:focus-visible { .log-entry:hover { border-color: color-mix(in srgb, var(--accent) 28%, var(--card-border) 72%); - box-shadow: 0 0.7rem 1.55rem rgba(60, 41, 12, 0.11); + box-shadow: 0 0.7rem 1.55rem var(--entry-shadow-hover); } .log-entry.private-self { - border-left: 0.35rem solid color-mix(in srgb, var(--private-self) 78%, #ffffff 22%); + border-left: 0.35rem solid color-mix(in srgb, var(--private-self) 78%, var(--surface-mix) 22%); } .log-entry.private-gm { - border-left: 0.35rem solid color-mix(in srgb, var(--private-gm) 78%, #ffffff 22%); + border-left: 0.35rem solid color-mix(in srgb, var(--private-gm) 78%, var(--surface-mix) 22%); } .log-entry.public { - border-left: 0.35rem solid color-mix(in srgb, var(--public) 70%, #ffffff 30%); + border-left: 0.35rem solid color-mix(in srgb, var(--public) 70%, var(--surface-mix) 30%); } .log-entry.private-generic { - border-left: 0.35rem solid color-mix(in srgb, var(--muted) 52%, #ffffff 48%); + border-left: 0.35rem solid color-mix(in srgb, var(--muted) 52%, var(--surface-mix) 48%); } .log-entry.expanded { @@ -786,12 +944,12 @@ select:focus-visible { } .log-entry.fresh { - border-color: color-mix(in srgb, #c79913 52%, var(--card-border) 48%); - box-shadow: 0 0.9rem 1.8rem rgba(199, 153, 19, 0.16); + border-color: color-mix(in srgb, var(--die-wild) 52%, var(--card-border) 48%); + box-shadow: 0 0.9rem 1.8rem var(--fresh-shadow); } .log-entry-toggle:hover { - background: color-mix(in srgb, var(--card) 84%, #ffffff 16%); + background: color-mix(in srgb, var(--card) 84%, var(--surface-mix) 16%); } .log-entry-toggle:focus-visible { @@ -855,21 +1013,21 @@ select:focus-visible { } .log-event-badge.positive { - border-color: #79a85d; - background: #e7f6da; - color: #235217; + border-color: var(--success-border); + background: var(--success-bg); + color: var(--success-text); } .log-event-badge.danger { - border-color: #c56b5a; - background: #ffe3dc; - color: #7d1f17; + border-color: var(--error-border); + background: var(--error-bg); + color: var(--error-text); } .log-event-badge.rare { - border-color: #b48b34; - background: #fff1c7; - color: #6d4c05; + border-color: var(--rare-border); + background: var(--rare-bg); + color: var(--rare-text); } .log-meta { @@ -885,7 +1043,7 @@ select:focus-visible { margin: 0 0.65rem 0.65rem; padding: 0.7rem 0.8rem 0.75rem; border-top: 1px solid color-mix(in srgb, var(--card-border) 38%, transparent 62%); - background: color-mix(in srgb, #ffffff 42%, var(--card) 58%); + background: color-mix(in srgb, var(--surface-mix) 42%, var(--card) 58%); border-radius: 0.7rem; } @@ -904,31 +1062,31 @@ select:focus-visible { } .badge.active { - border-color: #8f5f12; - background: #f6d28d; - color: #5d3808; + border-color: var(--active-border); + background: var(--active-bg); + color: var(--active-text); } .badge.public { - background: color-mix(in srgb, var(--public) 14%, #ffffff 86%); + background: color-mix(in srgb, var(--public) 14%, var(--surface-mix) 86%); color: var(--public); border-color: color-mix(in srgb, var(--public) 34%, transparent 66%); } .badge.private-self { - background: color-mix(in srgb, var(--private-self) 12%, #ffffff 88%); + background: color-mix(in srgb, var(--private-self) 12%, var(--surface-mix) 88%); color: var(--private-self); border-color: color-mix(in srgb, var(--private-self) 30%, transparent 70%); } .badge.private-gm { - background: color-mix(in srgb, var(--private-gm) 12%, #ffffff 88%); + background: color-mix(in srgb, var(--private-gm) 12%, var(--surface-mix) 88%); color: var(--private-gm); border-color: color-mix(in srgb, var(--private-gm) 30%, transparent 70%); } .badge.private-generic { - background: color-mix(in srgb, var(--muted) 12%, #ffffff 88%); + background: color-mix(in srgb, var(--muted) 12%, var(--surface-mix) 88%); color: var(--muted); border-color: color-mix(in srgb, var(--muted) 28%, transparent 72%); } @@ -993,7 +1151,7 @@ select:focus-visible { .skeleton-line { height: 0.85rem; border-radius: 0.4rem; - background: linear-gradient(90deg, #dfd2b7, #efe3c9, #dfd2b7); + background: var(--skeleton-bg); background-size: 220% 100%; animation: shimmer 1.1s linear infinite; } @@ -1003,8 +1161,8 @@ select:focus-visible { } .health-banner { - border: 1px solid #b77a29; - background: #fff2db; + border: 1px solid var(--health-border); + background: var(--health-bg); border-radius: 0.75rem; padding: 0.75rem; display: flex; @@ -1026,7 +1184,7 @@ select:focus-visible { justify-content: space-between; align-items: center; gap: 0.5rem; - border: 1px solid #b8a37b; + border: 1px solid var(--card-border); border-radius: 0.55rem; padding: 0.5rem; } @@ -1047,9 +1205,9 @@ select:focus-visible { gap: 0.45rem; align-self: flex-start; padding: 0.55rem 0.65rem; - border: 1px solid #b39f79; + border: 1px solid var(--card-border); border-radius: 0.45rem; - background: #f9f2e2; + background: var(--input-bg); color: var(--text); font-weight: 700; text-decoration: none; @@ -1069,15 +1227,15 @@ select:focus-visible { align-items: center; gap: 0.45rem; align-self: flex-start; - background: #f9f2e2; + background: var(--input-bg); color: var(--text); - border: 1px solid #b39f79; + border: 1px solid var(--card-border); } .add-row-icon { width: 1.2rem; height: 1.2rem; - border: 1px solid #8e7b57; + border: 1px solid var(--input-border); border-radius: 999px; display: inline-flex; align-items: center; @@ -1088,7 +1246,7 @@ select:focus-visible { .modal-overlay { position: fixed; inset: 0; - background: rgba(35, 25, 9, 0.55); + background: var(--modal-overlay); display: grid; place-items: center; z-index: 20; @@ -1118,7 +1276,7 @@ select:focus-visible { padding: 0.55rem; display: none; gap: 0.45rem; - background: rgba(241, 228, 201, 0.96); + background: var(--mobile-nav-bg); border-top: 1px solid var(--card-border); } @@ -1136,7 +1294,7 @@ select:focus-visible { border-radius: 0.6rem; border: 1px solid; padding: 0.55rem 0.7rem; - box-shadow: 0 6px 14px rgba(34, 24, 9, 0.22); + box-shadow: 0 6px 14px var(--toast-shadow); backdrop-filter: blur(4px); } @@ -1146,15 +1304,15 @@ select:focus-visible { } .toast.success { - background: #e8f7e8; - border-color: #78a978; - color: #1f5425; + background: var(--success-bg); + border-color: var(--success-border); + color: var(--success-text); } .toast.error { - background: #ffe9e5; - border-color: #bb6e62; - color: #7f2015; + background: var(--error-bg); + border-color: var(--error-border); + color: var(--error-text); } .sr-only { diff --git a/openapi/RpgRoller.json b/openapi/RpgRoller.json index 56f8d2f..d919673 100644 --- a/openapi/RpgRoller.json +++ b/openapi/RpgRoller.json @@ -1,4 +1,4 @@ -{ +{ "openapi": "3.0.1", "info": { "title": "RpgRoller API", @@ -156,6 +156,46 @@ } } }, + "/api/me/theme": { + "put": { + "operationId": "updateThemePreference", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateThemePreferenceRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Updated current user.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserSummary" + } + } + } + }, + "400": { + "description": "Validation error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Unauthorized." + } + } + } + }, "/api/campaigns": { "get": { "operationId": "getCampaigns", @@ -701,12 +741,27 @@ }, "displayName": { "type": "string" + }, + "roles": { + "type": "array", + "items": { + "type": "string" + } + }, + "themePreference": { + "type": "string", + "nullable": true, + "enum": [ + "light", + "dark" + ] } }, "required": [ "id", "username", - "displayName" + "displayName", + "roles" ] }, "MeResponse": { @@ -730,6 +785,21 @@ "user" ] }, + "UpdateThemePreferenceRequest": { + "type": "object", + "properties": { + "themePreference": { + "type": "string", + "enum": [ + "light", + "dark" + ] + } + }, + "required": [ + "themePreference" + ] + }, "RulesetDefinition": { "type": "object", "properties": {