10 Commits

Author SHA1 Message Date
2c99544baf Changed background images to webp 2026-05-28 19:32:42 +02:00
8e730af85d Fix Windows deploy remote script line endings 2026-05-18 21:13:50 +02:00
66607e51eb Light/Dark theming 2026-05-18 21:00:38 +02:00
ecc799ae7f Move campaign selector into header 2026-05-18 20:13:14 +02:00
ff28f70b51 Add Windows deploy script 2026-05-18 19:11:31 +02:00
20c8868744 remove stale script 2026-05-18 19:06:13 +02:00
b80e9f1aec updated docs 2026-05-18 19:05:39 +02:00
d74f8a65a9 removed stale scripts 2026-05-18 19:00:09 +02:00
c79bea86b6 removed stale doc 2026-05-18 18:59:23 +02:00
e7ae0e00c1 Background image 2026-05-05 02:35:05 +02:00
46 changed files with 1281 additions and 1010 deletions

View File

@@ -8,25 +8,8 @@ These tools are installed and available: Python3, geckodriver, Selenium
## Rules
- Prefer extracting code to a shared helper to be reused instead of duplicating code. Always keep high maintainability standards.
- Always place each newly created class into its own file. The file name must match the class name.
- When asked to begin working on a task, create a detailed implementation plan first, present the plan to the user, and ask for approval before beginning with the actual implementation.
- Don't make assumptions in the plan. If necessary, ask all clarifying questions before presenting the final plan.
- When an task is finished, perform a code review to evaluate if the change is clean and maintainable with high software engineering standards. Iterate on the code and repeat the review process until satisfied.
- If there's documnentation present, always keep it updated.
- After every iteration, evaluate if the test coverage would fall below 100%, and write tests if necessary.
- After every iteration, run `dotnet jb cleanupcode --build=False '$file1' '$file2' ...` for every C# file you touched.
- After every iteration, run `jb cleanupcode --build=False '$file1' '$file2' ...` for every C# file you touched.
- After every frontend change, verify the results using a geckodriver+Selenium run.
- When browser verification needs the app running, launch the app against a temporary copy of `src\RolemasterDb.App\rolemaster.db` so verification does not mutate the canonical DB.
### Git
- Never change the .gitignore file without consent.
- Keep changes small with minimal churn and commit often. If one iteration encompasses many smaller tasks with more than one commit, create a git branch and do the commits there. Let me review the branch before merging it back to master.
- When multiple commits are necessary, pause after every commit and ask the user to give a command to proceed.
- After every iteration, do a git commit with a brief summary of the changes as a commit message.
- If you find unexpected changes in the code (deletions, changes, diff results that were not communicated), never revert them and never restore the old state. Assume that those changes happened with intent.
- Never use `git restore`, `git checkout --`, reset commands, or equivalent rollback actions to discard local changes unless the user explicitly asks for that exact rollback.
### Dotnet CLI

View File

@@ -1 +1,26 @@
This is a linux environment, read `AGENTS.linux.md`.
# Agent Guide
Detect which operating system you're currently running on.
If this is a linux environment, read `AGENTS.linux.md`.
If this is a windows environment, read `AGENTS.windows.md`.
## Rules
- Prefer extracting code to a shared helper to be reused instead of duplicating code. Always keep high maintainability standards.
- Keep changes as small as possible, design solutions that achieve the goals with minimal churn.
- Always place each newly created class into its own file. The file name must match the class name.
- When asked to begin wor~~~~king on a task, create a detailed implementation plan first, present the plan to the user, and ask for approval before beginning with the actual implementation.
- Don't make assumptions in the plan. If necessary, ask all clarifying questions before presenting the final plan.
- When an task is finished, perform a code review to evaluate if the change is clean and maintainable with high software engineering standards. Iterate on the code and repeat the review process until satisfied.
- If there's documnentation present, always keep it updated.
- After every iteration, evaluate if the test coverage would fall below 100%, and write tests if necessary.
- When browser verification needs the app running, launch the app against a temporary copy of `src\RolemasterDb.App\rolemaster.db` so verification does not mutate the canonical DB.
### Git
- Never change the .gitignore file without consent.
- Keep changes small and commit often. If one iteration encompasses many smaller tasks with more than one commit, create a git branch and do the commits there. Let me review the branch before merging it back to master.
- When multiple commits are necessary, pause after every commit and ask the user to give a command to proceed.
- After every iteration, do a git commit with a brief summary of the changes as a commit message.
- If you find unexpected changes in the code (deletions, changes, diff results that were not communicated), never revert them and never restore the old state. Assume that those changes happened with intent.
- Never use `git restore`, `git checkout --`, reset commands, or equivalent rollback actions to discard local changes unless the user explicitly asks for that exact rollback.

View File

@@ -12,30 +12,14 @@ These tool paths should be used instead of any entry in the PATH environment var
## Rules
- Prefer extracting code to a shared helper to be reused instead of duplicating code. Always keep high maintainability standards.
- Keep changes as small as possible, design solutions that achieve the goals with minimal churn.
- Always place each newly created class into its own file. The file name must match the class name.
- When asked to begin working on a task, create a detailed implementation plan first, present the plan to the user, and ask for approval before beginning with the actual implementation.
- Don't make assumptions in the plan. If necessary, ask all clarifying questions before presenting the final plan.
- When an task is finished, perform a code review to evaluate if the change is clean and maintainable with high software engineering standards. Iterate on the code and repeat the review process until satisfied.
- After every iteration, run `dotnet jb cleanupcode --build=False '$file1' '$file2' ...` for every C# file you touched.
- After the implementation is finished, verify all changed files, and run `python D:\Code\crlf.py $file1 $file2 ...` only for files you recognize, in order to normalize all line endings of all touched files to CRLF.
- If there's documnentation present, always keep it updated.
- After every iteration, evaluate if the test coverage would fall below 100%, and write tests if necessary.
- After every iteration, run `jb cleanupcode --build=False '$file1' '$file2' ...` for every C# file you touched.
- After every frontend change, verify the results using an ephemeral Playwright run.
- For ad hoc verification in this repo, do not default to `npx playwright test` with a temp spec outside the repo.
- Prefer a repo-local ephemeral Node script under `artifacts_verify/` that imports `playwright` with `require('playwright')` and drives the browser directly.
- If using the Playwright test runner, use the repo-local CLI at `node_modules\.bin\playwright.cmd` and keep the spec inside the repo so local `node_modules` resolution works.
- Do not mix the global Playwright CLI with the repo-local `@playwright/test` package.
- When browser verification needs the app running, launch the app against a temporary copy of `src\RolemasterDb.App\rolemaster.db` so verification does not mutate the canonical DB.
### Git
- Never change the .gitignore file without consent.
- Keep changes small and commit often. If one iteration encompasses many smaller tasks with more than one commit, create a git branch and do the commits there. Let me review the branch before merging it back to master.
- When multiple commits are necessary, pause after every commit and ask the user to give a command to proceed.
- After every iteration, do a git commit with a brief summary of the changes as a commit message.
- If you find unexpected changes in the code (deletions, changes, diff results that were not communicated), never revert them and never restore the old state. Assume that those changes happened with intent.
- Never use `git restore`, `git checkout --`, reset commands, or equivalent rollback actions to discard local changes unless the user explicitly asks for that exact rollback.
### PowerShell

View File

@@ -1,419 +0,0 @@
# POSTMORTEM
## Executive Summary
RpgRoller failed in Firefox with RoboForm enabled because the authenticated workspace was built as a highly reactive Blazor Server surface that performs several structural rerenders immediately after login while assuming stable ownership of the rendered DOM.
RoboForm was the trigger, not the root cause.
The root cause was architectural:
- the app mixed static HTML auth, interactive Blazor Server UI, browser-managed session state, JavaScript fetch calls, and SSE live updates into one startup path
- the authenticated workspace bootstrap was driven from `OnAfterRenderAsync` and intentionally caused follow-up renders
- the root shell in [App.razor](/home/frank/Code/RpgRoller/RpgRoller/Components/App.razor) still branches on request-time `HttpContext` and session-cookie state
- the workspace render tree is large, form-heavy, and sensitive to DOM mutation during early batches
That combination made the app fragile under browser extensions that legitimately modify login and form-related DOM.
## Incident Summary
### User-visible symptoms
- Firefox in a normal profile crashed the Blazor circuit immediately after or around login
- the browser console reported:
- `Error: There was an error applying batch ...`
- `TypeError: can't access property "insertBefore", n.parentNode is null`
- the server logged `RemoteRenderer[100]` and terminated the circuit
- the UI often degraded into `Loading user...`, `Offline fallback`, or a partially rendered play screen before failing
### Scope
- The failure reproduced only in a normal Firefox profile with RoboForm enabled
- The failure did not reproduce in a private Firefox window
- The failure did not reproduce after disabling RoboForm
- The failure was not tied to a specific username or database row
### Trigger vs. Root Cause
Trigger:
- RoboForm mutated form-related DOM in the page
Root cause:
- the app architecture depended on Blazor Server retaining stable ownership of a DOM subtree that was undergoing immediate, multi-batch structural changes during startup
## Current Architecture
### Root shell
The application entry point is [Program.cs](/home/frank/Code/RpgRoller/RpgRoller/Program.cs):
- `AddRazorComponents().AddInteractiveServerComponents()`
- `MapRazorComponents<App>().AddInteractiveServerRenderMode()`
- `AddScoped<RpgRollerApiClient>()`
- `AddScoped<WorkspaceQueryService>()`
The root component is [App.razor](/home/frank/Code/RpgRoller/RpgRoller/Components/App.razor). It does two different things at `/`:
- if the incoming request has no valid session cookie, it renders [StaticAuthPage.razor](/home/frank/Code/RpgRoller/RpgRoller/Components/Pages/HomeControls/StaticAuthPage.razor) as plain HTML
- otherwise it boots the Blazor app with `<Routes @rendermode="InteractiveServer(prerender: false)" />`
This decision is made from request-time state:
- `HttpContext`
- request path
- session cookie
- `IGameService.GetUserBySession`
### Auth flow
The auth page is no longer a Blazor form.
[StaticAuthPage.razor](/home/frank/Code/RpgRoller/RpgRoller/Components/Pages/HomeControls/StaticAuthPage.razor) renders plain HTML forms, and [rpgroller-api.js](/home/frank/Code/RpgRoller/RpgRoller/wwwroot/js/rpgroller-api.js) binds submit handlers that:
- validate in JS
- call `/api/auth/register` or `/api/auth/login` via `fetch`
- on login, force a full `window.location.assign("/")`
This means the app has one browser experience before login and a different ownership model after login.
### Authenticated workspace
The authenticated `/` route is [Home.razor](/home/frank/Code/RpgRoller/RpgRoller/Components/Pages/Home.razor), which only renders `<Workspace LoggedOut="OnLoggedOutAsync" />`.
The real composition root is [Workspace.razor.cs](/home/frank/Code/RpgRoller/RpgRoller/Components/Pages/Workspace.razor.cs). It wires:
- `WorkspaceSessionCoordinator`
- `WorkspaceCampaignScopeCoordinator`
- `WorkspaceCampaignCoordinator`
- `WorkspacePlayCoordinator`
- `WorkspaceAdminCoordinator`
- `WorkspaceLiveStateController`
- `WorkspaceFeedbackService`
The current startup path is driven by `OnAfterRenderAsync`:
1. first interactive render occurs
2. `Session.InitializeAsync()` runs
3. `StateHasChanged()` is invoked
4. later renders enable more controls such as the character controls and custom roll composer
### State channels
The workspace state is not owned by one subsystem. It is spread across four channels:
1. Blazor component state in [WorkspaceState.cs](/home/frank/Code/RpgRoller/RpgRoller/Components/Pages/WorkspaceState.cs)
2. browser `sessionStorage` via `rpgroller-api.js`
3. browser `fetch` requests through [RpgRollerApiClient.cs](/home/frank/Code/RpgRoller/RpgRoller/Components/RpgRollerApiClient.cs) and [WorkspaceQueryService.cs](/home/frank/Code/RpgRoller/RpgRoller/Components/WorkspaceQueryService.cs)
4. SSE live updates from `/api/events/state` via [StateEventEndpoints.cs](/home/frank/Code/RpgRoller/RpgRoller/Api/StateEventEndpoints.cs)
### Backend state model
The backend is not stateless HTTP over a database. [GameService.cs](/home/frank/Code/RpgRoller/RpgRoller/Services/GameService.cs) builds an in-memory runtime state from SQLite at startup using [GameStateStore.cs](/home/frank/Code/RpgRoller/RpgRoller/Services/GameStateStore.cs), then serves reads and writes against that state.
This matters because the frontend already has multiple live state concepts:
- the Blazor circuit
- the JS app state
- the SSE stream
- the server-side runtime store
That complexity is manageable only if the UI ownership boundaries stay clean. They did not.
## Architectural Timeline
### February 25, 2026
Blazor was introduced as the frontend host:
- `a8ee637` `Scaffold Blazor frontend host and root components`
- `35c60c4` `Replace frontend with Blazor UX implementation`
This established Blazor Server as the UI owner.
### February 26 to April 5, 2026
The workspace became denser and more interactive:
- `c3aa0d4` `Overhaul workspace UX for denser play workflow`
- `bf3a6fa` `Persist roll visibility preference across workspace reloads`
- `54aabc6` `Unify play management and admin screens in workspace`
- `6ea91ee` `Add targeted workspace live refresh`
- `e42c0fb` `Load campaign logs incrementally`
- `9e6e6fe` `Add custom campaign roll composer`
- `4af1c87` through `b291d05` extracted coordinators and simplified the composition root
This refactor improved code organization, but it also increased the number of reactive moving parts:
- more persistent UI state in `sessionStorage`
- more conditional screen branching inside one workspace root
- more input-heavy controls on the default play screen
- a live SSE side channel that can trigger refreshes after startup
### May 2 to May 4, 2026
This period contains direct evidence of mitigation attempts after the Firefox failure surfaced:
- `2d2ed56` `Isolate anonymous auth page from Blazor`
- `1f19bf7` `Restore workspace prerender and auth errors`
- `231b0ac` `Remove workspace session-token coupling`
- `da81358` `Delay workspace render until session init completes`
- `e0b7d27` `Stage workspace controls after bootstrap`
These commits are valuable evidence because they show the app was being repaired at the symptom boundary:
- first by removing auth from Blazor ownership
- then by changing prerender behavior
- then by removing `HttpContext`-captured session coupling from workspace queries
- then by staging workspace startup
None of those changes removed the underlying architectural fragility: a Blazor Server workspace that still reshapes a large, extension-visible DOM over several early render batches.
## Root Causes
### 1. DOM ownership was not treated as a hard architectural boundary
The app used Blazor Server for a form-heavy authenticated workspace, while also operating in a browser environment where password managers are expected to inject or wrap form controls.
In principle that can work, but only when the rendered DOM is stable enough that third-party mutation does not race against structural rerenders.
RpgRoller violated that assumption:
- immediate post-login renders reshaped the workspace
- input-bearing controls were mounted during startup
- later state syncs continued changing the same subtree
That made the DOM ownership contract weak.
### 2. Startup was centered on `OnAfterRenderAsync` instead of a stable initial model
[Workspace.razor.cs](/home/frank/Code/RpgRoller/RpgRoller/Components/Pages/Workspace.razor.cs) drives initialization from `OnAfterRenderAsync`, then explicitly schedules more rerenders.
That has two consequences:
- the first visible authenticated frame is not the final intended frame
- the renderer must apply several batches while the browser is already free to run extensions against the DOM
This is a poor fit for DOM-mutating extensions.
### 3. The root shell still depends on request-time `HttpContext`
[App.razor](/home/frank/Code/RpgRoller/RpgRoller/Components/App.razor) still uses `HttpContext` and the session cookie to decide whether to render:
- static auth HTML
- or the interactive app
Even after removing the old workspace session-token accessor, the root shell still relies on request-only state to choose the subtree for `/`.
This is a fragile architectural seam because the app is half request-rendered and half interactive, with the split encoded in the component tree itself.
### 4. Too many reactive state channels were active during the same startup window
During or shortly after login, the workspace may react to:
- `sessionStorage` reads
- API reads through `fetch`
- Blazor rerenders from `StateHasChanged`
- SSE connection state transitions
- SSE state events
That is too much coordination for a large render tree if DOM stability is required.
### 5. The workspace root remained too large and too structurally dynamic
[Workspace.razor](/home/frank/Code/RpgRoller/RpgRoller/Components/Pages/Workspace.razor) controls:
- header
- play screen
- campaign management
- admin screen
- toasts
- character modals
- Rolemaster modal
The play screen itself contains multiple conditional branches and input surfaces.
Even though code-behind files and coordinators improved organization, the rendered root still has a large rerender blast radius.
### 6. Documentation drift hid the architecture change
The current [README.md](/home/frank/Code/RpgRoller/README.md) still describes:
- `Home.razor` as a gateway that switches between loading, auth, and authenticated workspace views
- `WorkspaceQueryService` as “server-side read model access”
Neither description matches the current code:
- `Home.razor` now just renders `Workspace`
- `App.razor` became the real gateway
- `WorkspaceQueryService` now calls browser `fetch` through `RpgRollerApiClient`
This kind of drift usually means the architecture has moved faster than the design was reevaluated.
## Why the Failure Was Hard to Fix Incrementally
The failure was not caused by one broken line. It emerged from several acceptable local decisions that interacted badly:
- static auth was added to avoid Blazor auth-page failures
- workspace prerender behavior was changed to satisfy session bootstrap
- direct server-side workspace reads were removed to avoid `HttpContext` coupling
- render staging was added to reduce early DOM churn
Each change improved one seam while leaving the overall architecture intact.
That is why the issue kept moving:
- first the auth page crashed
- then login worked but workspace bootstrap stalled
- then the play view partially rendered but later crashed
The architecture allowed the failure to migrate between phases instead of disappearing.
## Evidence From Recent Fix Attempts
### `2d2ed56` on May 2, 2026
`Isolate anonymous auth page from Blazor`
What it changed:
- added `StaticAuthPage.razor`
- moved login/register handling into `rpgroller-api.js`
- changed `App.razor` to serve static auth HTML when unauthenticated
What it revealed:
- removing Blazor from the auth page improved the anonymous path
- the underlying crash still existed in the authenticated workspace path
### `1f19bf7` on May 2, 2026
`Restore workspace prerender and auth errors`
What it changed:
- adjusted render mode behavior again
- kept better auth error reporting
What it revealed:
- workspace startup was still entangled with earlier render-mode decisions
- the app was using render-mode changes as a corrective mechanism rather than as a stable architecture choice
### `231b0ac` on May 3, 2026
`Remove workspace session-token coupling`
What it changed:
- deleted `WorkspaceSessionTokenAccessor`
- changed `WorkspaceQueryService` to use API calls instead of direct service access
What it revealed:
- the previous architecture had leaked request-time session access into interactive workspace startup
- removing that coupling was necessary, but not sufficient, because the DOM ownership problem remained
### `da81358` on May 4, 2026
`Delay workspace render until session init completes`
What it changed:
- replaced the early workspace UI with a loading shell
What it revealed:
- broad render suppression was too blunt
- it masked, rather than removed, the actual failing rerender path
### `e0b7d27` on May 4, 2026
`Stage workspace controls after bootstrap`
What it changed:
- restored the base workspace
- deferred some input-heavy controls to later batches
What it revealed:
- the crash moved later in startup
- the base play view could survive, but later structural updates still failed
Taken together, these commits are evidence that the app was being pushed toward compatibility through localized mitigations, while the larger architecture still tolerated unstable startup ownership.
## What Actually Failed
The practical failure mode was:
1. login succeeded
2. the authenticated workspace circuit started
3. early render batches built or reshaped a large DOM subtree
4. RoboForm touched form-related DOM inside that subtree
5. Blazor attempted to apply a later batch using DOM assumptions that were no longer true
6. the browser-side batch apply failed with `insertBefore ... parentNode is null`
7. the server terminated the circuit
The `Offline fallback` label was mostly a consequence:
- once the circuit failed, live-state coordination could not complete cleanly
- the connection-state UI then reflected that degraded state
## Findings
### Primary finding
The authenticated workspace should not have been architected as a multi-batch, structurally dynamic, form-heavy startup surface if compatibility with password managers and other DOM-mutating extensions is a requirement.
### Secondary findings
- `App.razor` became a hidden architecture boundary without being treated as one
- the workspace composition root is still too structurally broad
- frontend ownership is split between Blazor and handwritten JS in a way that complicates startup reasoning
- live updates were added as another reactivity source before the UI ownership model was made robust
- documentation no longer described the actual architecture, making corrective design work harder
## Remediation Directions
These are architectural directions, not the implementation plan.
### 1. Choose a single ownership model for the authenticated shell
The authenticated shell should not be partly “request-decided” and partly “interactive-decided” in a component tree that still relies on request-time state.
### 2. Stop using `OnAfterRenderAsync` as the main workspace bootstrap orchestrator
The authenticated workspace needs a stable initial render contract with fewer structural follow-up diffs.
### 3. Reduce startup-state multiplicity
The startup path should not require simultaneous coordination across:
- Blazor state
- `sessionStorage`
- `fetch`
- SSE
at least not before the UI is stable.
### 4. Shrink the render blast radius
The workspace root should own less structural branching. The more isolated the screen and control subtrees are, the less likely a third-party DOM mutation is to invalidate a broad diff.
### 5. Treat extension compatibility as a design requirement
Password managers are not an edge case for login and form-driven applications. The UI architecture must assume that form controls can be wrapped, annotated, or moved by browser software.
### 6. Realign documentation with the code
The design notes and README need to describe the actual architecture before a stable fix plan is made. Otherwise future changes will continue to optimize around an outdated mental model.
## Conclusion
RpgRoller did not fail because RoboForm existed. It failed because the apps frontend architecture evolved into a shape where the authenticated workspace depended on fragile early render batches, mixed ownership boundaries, and multiple overlapping state channels.
RoboForm exposed that weakness reliably.
The correct next step is not another isolated workaround. The correct next step is to redesign the authenticated shell and workspace startup path around stable DOM ownership and simpler state flow.

View File

@@ -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.webp` and `RpgRoller/wwwroot/images/dark.webp`: 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

View File

@@ -1,4 +1,4 @@
namespace RpgRoller.Tests;
namespace RpgRoller.Tests;
public sealed class AuthApiTests(WebApplicationFactory<Program> factory) : ApiTestBase(factory)
{
@@ -12,8 +12,7 @@ public sealed class AuthApiTests(WebApplicationFactory<Program> 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<Program> factory) : ApiTe
var me = await GetAsync<MeResponse>(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<UpdateThemePreferenceRequest, UserSummary>(client, "/api/me/theme", new("dark"));
Assert.Equal("dark", themeUser.ThemePreference);
var themedMe = await GetAsync<MeResponse>(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<Program> 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);

View File

@@ -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<string>(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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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<Ok<UserSummary>, BadRequest<ApiError>, UnauthorizedHttpResult> (UpdateThemePreferenceRequest request, HttpContext context, IGameService game) =>
{
var result = game.UpdateThemePreference(context.GetRequiredSessionToken(), request.ThemePreference);
return ApiResultMapper.ToApiResult(result);
});
return group;
}
}

View File

@@ -1,4 +1,4 @@
@using RpgRoller.Components.Pages.HomeControls
@using RpgRoller.Components.Pages.HomeControls
@attribute [ExcludeFromCodeCoverage]
<!DOCTYPE html>
@@ -8,6 +8,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<base href="@BaseHref"/>
<title>RpgRoller</title>
<script>
document.documentElement.dataset.theme = window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
</script>
<link rel="stylesheet" href="@Assets["styles.css"]"/>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

View File

@@ -1,9 +1,8 @@
@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Components
@using RpgRoller.Components.Pages.HomeControls
<CampaignManagementPanel
Campaigns="Workspace.State.Campaigns"
SelectedCampaignId="Workspace.State.SelectedCampaignId"
SelectedCampaign="Workspace.State.SelectedCampaign"
Rulesets="Workspace.State.Rulesets"
IsMutating="Workspace.State.IsMutating"
@@ -11,7 +10,6 @@
CanEditCharacter="Workspace.Campaigns.CanEditCharacter"
CanDeleteCharacter="Workspace.Campaigns.CanDeleteCharacter"
CanDeleteCampaign="Workspace.State.CanDeleteSelectedCampaign"
CampaignSelectionChanged="OnCampaignSelectionChangedAsync"
CampaignCreated="Workspace.Campaigns.OnCampaignCreatedAsync"
DeleteCampaignRequested="Workspace.Campaigns.DeleteSelectedCampaignAsync"
CreateCharacterRequested="Workspace.Campaigns.OpenCreateCharacterModal"
@@ -22,10 +20,4 @@
@code {
[Parameter, EditorRequired] public WorkspacePageContext Workspace { get; set; } = null!;
private async Task OnCampaignSelectionChangedAsync(ChangeEventArgs args)
{
await Workspace.Campaigns.OnCampaignSelectionChangedAsync(args);
await Workspace.RequestRefreshAsync();
}
}

View File

@@ -1,4 +1,4 @@
<header class="workspace-header">
<header class="workspace-header">
<div class="header-row">
<h1>@Title</h1>
@if (User is null)
@@ -15,7 +15,23 @@
}
@if (ShowCampaign)
{
<p class="header-campaign">Campaign: <strong>@(CampaignName ?? "No campaign selected")</strong></p>
<div class="header-campaign">
<label for="@CampaignSelectId">Campaign</label>
@if (Campaigns.Count == 0)
{
<span>No campaigns yet</span>
}
else
{
<select id="@CampaignSelectId"
@onchange="CampaignSelectionChanged">
@foreach (var campaign in Campaigns)
{
<option value="@campaign.Id" selected="@(campaign.Id == SelectedCampaignId)">@campaign.Name</option>
}
</select>
}
</div>
}
<div class="header-connection-cell">
@if (ShowConnectionState)
@@ -24,6 +40,13 @@
}
</div>
<a href="" class="logout-link" @onclick:preventDefault="true" @onclick="LogoutRequested">Logout</a>
<button type="button"
class="theme-toggle"
aria-label="@ThemeToggleAriaLabel"
title="@ThemeToggleAriaLabel"
@onclick="ThemeToggleRequested">
<span aria-hidden="true">@ThemeToggleLabel</span>
</button>
@if (MenuItems.Count > 0)
{
<div class="header-menu-wrap">

View File

@@ -1,4 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using RpgRoller.Contracts;
@@ -12,31 +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 string? CampaignName { get; set; }
[Parameter]
public bool ShowCampaign { get; set; }
[Parameter] public bool ShowConnectionState { get; set; } = true;
[Parameter]
public IReadOnlyList<CampaignSummary> Campaigns { get; set; } = [];
[Parameter] public string ConnectionStateLabel { get; set; } = "Offline fallback";
[Parameter]
public Guid? SelectedCampaignId { get; set; }
[Parameter] public string ConnectionStateCssClass { get; set; } = "offline";
[Parameter]
public string CampaignSelectId { get; set; } = "header-campaign-select";
[Parameter] public bool IsMenuOpen { get; set; }
[Parameter]
public EventCallback<ChangeEventArgs> CampaignSelectionChanged { get; set; }
[Parameter] public string MenuButtonId { get; set; } = "screen-menu-button";
[Parameter]
public bool ShowConnectionState { get; set; } = true;
[Parameter] public string MenuId { get; set; } = "screen-menu";
[Parameter]
public string ConnectionStateLabel { get; set; } = "Offline fallback";
[Parameter] public IReadOnlyList<AppHeaderMenuItem> MenuItems { get; set; } = [];
[Parameter]
public string ConnectionStateCssClass { get; set; } = "offline";
[Parameter] public EventCallback ToggleMenuRequested { get; set; }
[Parameter]
public bool IsMenuOpen { get; set; }
[Parameter] public EventCallback LogoutRequested { get; set; }
[Parameter]
public string MenuButtonId { get; set; } = "screen-menu-button";
[Parameter]
public string MenuId { get; set; } = "screen-menu";
[Parameter]
public IReadOnlyList<AppHeaderMenuItem> MenuItems { 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

View File

@@ -1,4 +1,4 @@
<main class="management-screen">
<main class="management-screen">
<section class="card">
<div class="section-head">
<h2>Campaign</h2>
@@ -9,13 +9,14 @@
}
else
{
<label for="campaign-select">Current campaign</label>
<select id="campaign-select" @onchange="CampaignSelectionChanged">
@foreach (var campaign in Campaigns)
<div class="campaign-current">
<span>Current campaign</span>
<strong>@(SelectedCampaign is null ? "No campaign selected" : SelectedCampaign.Name)</strong>
@if (SelectedCampaign is not null)
{
<option value="@campaign.Id" selected="@(campaign.Id == SelectedCampaignId)">@campaign.Name (@campaign.RulesetId), GM: @campaign.Gm.DisplayName, @campaign.CharacterCount characters</option>
<p>@SelectedCampaign.RulesetId, GM: @SelectedCampaign.Gm.DisplayName, @SelectedCampaign.Characters.Length characters</p>
}
</select>
</div>
}
<button type="button"
@@ -129,4 +130,4 @@
</form>
</section>
</div>
}
}

View File

@@ -1,4 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using RpgRoller.Contracts;
@@ -74,9 +74,6 @@ public partial class CampaignManagementPanel
[Parameter]
public IReadOnlyList<CampaignSummary> Campaigns { get; set; } = [];
[Parameter]
public Guid? SelectedCampaignId { get; set; }
[Parameter]
public CampaignRoster? SelectedCampaign { get; set; }
@@ -98,9 +95,6 @@ public partial class CampaignManagementPanel
[Parameter]
public bool CanDeleteCampaign { get; set; }
[Parameter]
public EventCallback<ChangeEventArgs> CampaignSelectionChanged { get; set; }
[Parameter]
public EventCallback<Guid> CampaignCreated { get; set; }

View File

@@ -1,4 +1,4 @@
<section class="card character-panel">
<section class="card character-panel">
@if (IsCampaignDataLoading)
{
<div class="skeleton-stack">
@@ -9,7 +9,7 @@
}
else if (SelectedCampaign is null)
{
<p class="empty">No campaign selected. Choose one in Campaign Management.</p>
<p class="empty">No campaign selected. Choose one in the header.</p>
}
else if (SelectedCampaign.Characters.Length == 0)
{

View File

@@ -1,4 +1,4 @@
@using RpgRoller.Components.Pages.HomeControls
@using RpgRoller.Components.Pages.HomeControls
<div class="@AppCssClass">
<p class="sr-only" aria-live="polite">@State.LiveAnnouncement</p>
@@ -17,7 +17,9 @@
<AppHeader
User="State.User"
ShowCampaign="@ShowCampaignInHeader"
CampaignName="@State.SelectedCampaignName"
Campaigns="State.Campaigns"
SelectedCampaignId="State.SelectedCampaignId"
CampaignSelectionChanged="OnHeaderCampaignSelectionChangedAsync"
ShowConnectionState="@ShowConnectionStateInHeader"
ConnectionStateLabel="@State.ConnectionStateLabel"
ConnectionStateCssClass="@State.ConnectionStateCssClass"
@@ -26,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)

View File

@@ -1,4 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using RpgRoller.Components.Pages.HomeControls;
@@ -84,6 +84,12 @@ public partial class Workspace : IAsyncDisposable
return Task.CompletedTask;
}
private async Task OnHeaderCampaignSelectionChangedAsync(ChangeEventArgs args)
{
await Campaigns.OnCampaignSelectionChangedAsync(args);
await RequestRefreshAsync();
}
private Task RedirectToPlayAsync()
{
if (IsPlayRoute)
@@ -220,4 +226,4 @@ public partial class Workspace : IAsyncDisposable
private WorkspaceCampaignScopeCoordinator? m_Scope;
private WorkspaceSessionCoordinator? m_Session;
private Task? InitializationTask { get; set; }
}
}

View File

@@ -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<bool> isAdminRoute,
Func<Task> redirectToPlayAsync,
Func<Guid?, Task> reloadCampaignsAsync,
Func<Task> reloadCharacterCampaignOptionsAsync,
Func<Task> refreshCampaignScopeAsync,
Func<Task> requestRefreshAsync,
Func<Task> syncStateEventsAsync,
Func<Task> stopStateEventsAsync,
Func<Task> ensureAdminUsersLoadedAsync,
Action resetCampaignLogDetailState,
Func<string?, Task> onLoggedOutAsync)
public sealed class WorkspaceSessionCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, IJSRuntime js, RpgRollerApiClient apiClient, WorkspaceQueryService workspaceQuery, Func<bool> isAdminRoute, Func<Task> redirectToPlayAsync, Func<Guid?, Task> reloadCampaignsAsync, Func<Task> reloadCharacterCampaignOptionsAsync, Func<Task> refreshCampaignScopeAsync, Func<Task> requestRefreshAsync, Func<Task> syncStateEventsAsync, Func<Task> stopStateEventsAsync, Func<Task> ensureAdminUsersLoadedAsync, Action resetCampaignLogDetailState, Func<string?, Task> 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<string?>("rpgRollerApi.getSessionValue", RollVisibilitySessionKey);
var storedRollVisibility = await js.InvokeAsync<string?>("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<UserSummary>("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<string>("rpgRollerApi.getSystemTheme");
state.ThemePreference = NormalizeThemePreference(systemThemePreference);
await js.InvokeVoidAsync("rpgRollerApi.applyTheme", state.ThemePreference);
try
{
state.User = await apiClient.RequestAsync<UserSummary>("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";

View File

@@ -1,4 +1,4 @@
using RpgRoller.Components.Pages.HomeControls;
using RpgRoller.Components.Pages.HomeControls;
using RpgRoller.Contracts;
using RpgRoller.Domain;
@@ -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<string> 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; }
@@ -91,10 +88,6 @@ public sealed class WorkspaceState
public HashSet<Guid> CampaignLogDetailsLoading { get; } = [];
public Dictionary<Guid, string> CampaignLogDetailErrors { get; } = [];
public string? SelectedCampaignName => SelectedCampaign?.Name ??
Campaigns.FirstOrDefault(campaign => campaign.Id == SelectedCampaignId)
?.Name;
public CharacterSummary? SelectedCharacter =>
SelectedCampaign?.Characters.FirstOrDefault(character => character.Id == SelectedCharacterId);
@@ -106,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);
}
}
@@ -130,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;
}
@@ -174,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;
}

View File

@@ -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<string> Roles);
public sealed record UserSummary(Guid Id, string Username, string DisplayName, IReadOnlyList<string> 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<string> Roles);
public sealed record UpdateUserRolesRequest(IReadOnlyList<string> Roles);

View File

@@ -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

View File

@@ -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<RpgRollerDbContext> 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();
});

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -0,0 +1,273 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("GmUserId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Ruleset")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long>("Version")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("GmUserId");
b.ToTable("Campaigns");
});
modelBuilder.Entity("RpgRoller.Domain.Character", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("CampaignId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<Guid>("OwnerUserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CampaignId");
b.HasIndex("OwnerUserId");
b.ToTable("Characters");
});
modelBuilder.Entity("RpgRoller.Domain.RollLogEntry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Breakdown")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<Guid>("CampaignId")
.HasColumnType("TEXT");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<string>("Dice")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Result")
.HasColumnType("INTEGER");
b.Property<Guid>("RollerUserId")
.HasColumnType("TEXT");
b.Property<Guid>("SkillId")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("TimestampUtc")
.HasColumnType("TEXT");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("AllowFumble")
.HasColumnType("INTEGER");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<string>("DiceRollDefinition")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int?>("FumbleRange")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<bool>("RolemasterAutoRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<Guid?>("SkillGroupId")
.HasColumnType("TEXT");
b.Property<int>("WildDice")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CharacterId");
b.HasIndex("SkillGroupId");
b.ToTable("Skills");
});
modelBuilder.Entity("RpgRoller.Domain.SkillGroup", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("AllowFumble")
.HasColumnType("INTEGER");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<string>("DiceRollDefinition")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int?>("FumbleRange")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("WildDice")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CharacterId");
b.ToTable("SkillGroups");
});
modelBuilder.Entity("RpgRoller.Domain.UserAccount", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("ActiveCharacterId")
.HasColumnType("TEXT");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Roles")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("ThemePreference")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("UsernameNormalized")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UsernameNormalized")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("RpgRoller.Domain.UserSession", b =>
{
b.Property<string>("Token")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Token");
b.HasIndex("UserId");
b.ToTable("Sessions");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RpgRoller.Migrations
{
/// <inheritdoc />
public partial class AddUserThemePreference : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(name: "ThemePreference", table: "Users", type: "TEXT", maxLength: 16, nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "ThemePreference", table: "Users");
}
}
}

View File

@@ -224,6 +224,10 @@ namespace RpgRoller.Migrations
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("ThemePreference")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(64)

View File

@@ -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<U
DisplayName = displayName.Trim(),
PasswordHash = string.Empty,
Roles = stateStore.UsersById.Count == 0 ? UserRoles.Admin : string.Empty,
ActiveCharacterId = null
ActiveCharacterId = null,
ThemePreference = null
};
user.PasswordHash = passwordHasher.HashPassword(user, password);
@@ -112,6 +113,23 @@ public sealed class GameAuthService(GameStateStore stateStore, IPasswordHasher<U
}
}
public ServiceResult<UserSummary> UpdateThemePreference(string sessionToken, string themePreference)
{
lock (stateStore.Gate)
{
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
if (user is null)
return ServiceResult<UserSummary>.Failure("unauthorized", "You must be logged in.");
if (!ThemePreferences.IsSupported(themePreference))
return ServiceResult<UserSummary>.Failure("invalid_theme_preference", "Theme preference must be light or dark.");
user.ThemePreference = ThemePreferences.Normalize(themePreference);
persistenceService.PersistStateLocked();
return ServiceResult<UserSummary>.Success(GameDtoMapper.ToUserSummary(user));
}
}
private UserSession CreateSession(Guid userId)
{
var token = Guid.NewGuid().ToString("N");

View File

@@ -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)

View File

@@ -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<RpgRollerDbContext>
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;

View File

@@ -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<UserSummary> UpdateThemePreference(string sessionToken, string themePreference)
{
return m_AuthService.UpdateThemePreference(sessionToken, themePreference);
}
public ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId)
{
return m_CampaignService.CreateCampaign(sessionToken, name, rulesetId);

View File

@@ -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
};
}

View File

@@ -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<MeResponse> GetMe(string sessionToken);
ServiceResult<UserSummary> UpdateThemePreference(string sessionToken, string themePreference);
ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId);
ServiceResult<IReadOnlyList<CampaignSummary>> GetCampaigns(string sessionToken);

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

View File

@@ -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,

View File

@@ -1,8 +1,9 @@
:root {
:root {
color-scheme: light;
--bg-top: #f7f0d8;
--bg-bottom: #ecdfc4;
--button-hover: #dccfb4;
--card: #fffaf0;
--card: #fffaf0e0;
--card-border: #c3b28b;
--text: #2b2418;
--muted: #6a5b3f;
@@ -14,6 +15,147 @@
--public: #2d6645;
--private-self: #4f3a8f;
--private-gm: #915119;
--page-background: url("/images/light.webp");
--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.webp");
--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);
}
* {
@@ -27,9 +169,16 @@ body {
height: 100%;
}
html {
background-image: var(--page-background);
background-position: center;
background-repeat: no-repeat;
background-size: cover;
background-attachment: fixed;
}
body {
background: radial-gradient(circle at 15% 10%, rgba(255, 255, 255, 0.32), transparent 45%),
linear-gradient(165deg, var(--bg-top), var(--bg-bottom));
background: transparent;
color: var(--text);
font-family:
"Baloo 2",
@@ -93,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;
@@ -113,14 +262,33 @@ h3 {
font-size: 1.15rem;
}
.header-identity,
.header-campaign {
.header-identity {
margin: 0;
white-space: nowrap;
}
.header-campaign {
display: flex;
align-items: center;
gap: 0.35rem;
color: var(--muted);
min-width: 12rem;
white-space: nowrap;
}
.header-campaign label {
font-weight: 700;
}
.header-campaign select {
max-width: 16rem;
min-width: 9rem;
padding: 0.25rem 0.45rem;
}
.header-campaign span {
font-weight: 700;
color: var(--text);
}
.header-connection-cell {
@@ -139,7 +307,7 @@ h3 {
}
.card {
background: color-mix(in srgb, var(--card) 94%, #ffffff 6%);
background: var(--card);
border: 1px solid var(--card-border);
border-radius: 0.8rem;
padding: 0.7rem;
@@ -149,6 +317,20 @@ h3 {
gap: 0.75rem;
}
.campaign-current {
display: grid;
gap: 0.15rem;
}
.campaign-current span,
.campaign-current p {
color: var(--muted);
}
.campaign-current p {
margin: 0;
}
.auth-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -169,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;
}
@@ -189,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 {
@@ -287,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 {
@@ -302,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;
}
@@ -313,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;
@@ -374,8 +556,9 @@ select:focus-visible {
display: flex;
align-items: center;
justify-content: space-between;
background-color: #f8f0de;
padding-left: 0.1rem;
background-color: var(--skill-group-bg);
padding: 0.1rem;
border-top: 1px solid var(--card-border);
gap: 0.5rem;
}
@@ -416,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);
}
@@ -428,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;
@@ -479,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;
@@ -496,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 {
@@ -509,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 {
@@ -525,7 +708,7 @@ select:focus-visible {
}
.logout-link:hover {
color: #6b2419;
color: var(--accent-2-hover);
}
.logout-link:focus-visible {
@@ -533,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;
@@ -556,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;
@@ -567,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;
}
@@ -605,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,
@@ -635,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 {
@@ -671,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) {
@@ -686,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 {
@@ -698,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;
}
@@ -721,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 {
@@ -745,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 {
@@ -814,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 {
@@ -844,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;
}
@@ -863,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%);
}
@@ -952,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;
}
@@ -962,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;
@@ -985,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;
}
@@ -1006,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;
@@ -1028,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;
@@ -1047,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;
@@ -1077,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);
}
@@ -1095,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);
}
@@ -1105,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 {
@@ -1172,6 +1371,15 @@ select:focus-visible {
white-space: normal;
}
.header-campaign {
flex-wrap: wrap;
min-width: 0;
}
.header-campaign select {
max-width: 100%;
}
.mobile-bottom-nav {
display: flex;
}

View File

@@ -1,17 +0,0 @@
param(
[string]$Password,
[switch]$SkipRecycle,
[switch]$SkipMigrations
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$scriptPath = Join-Path $PSScriptRoot "scripts/deploy-ftp1.ps1"
$profilePath = Join-Path $PSScriptRoot "scripts/deploy-ftp.profile.psd1"
& $scriptPath `
-ProfilePath $profilePath `
-Password $Password `
-SkipRecycle:$SkipRecycle `
-SkipMigrations:$SkipMigrations

View File

@@ -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": {

View File

@@ -1,23 +0,0 @@
@{
ProjectPath = "..\RpgRoller\RpgRoller.csproj"
Configuration = "Release"
Runtime = "win-x64"
PublishDir = "%TEMP%\RpgRoller-publish"
SelfContained = $false
WinScpPath = "C:\Users\frank\AppData\Local\Programs\WinSCP\WinSCP.com"
RemoteDir = "/httpdocs/rpgroller"
BasePath = "/rpgroller"
FtpHost = "xTr1m.com"
FtpUser = "xTr1m"
RecycleAppPool = $true
AppPoolName = "xTr1m.com(domain)(4.0)(pool)"
WinRmComputer = "xTr1m.com"
WinRmCredentialUser = "Administrator"
UseWinRmHttps = $true
WinRmAuth = "Basic"
RunEfMigrations = $false
RemoteSitePath = "C:\Inetpub\vhosts\xTr1m.com\httpdocs\rpgroller"
}

View File

@@ -1,255 +0,0 @@
param(
[string]$ProfilePath = (Join-Path $PSScriptRoot "deploy-ftp.profile.psd1"),
[string]$Password,
[switch]$SkipRecycle,
[switch]$SkipMigrations
)
<#
.SYNOPSIS
Publish the app and mirror output to an FTP-deployed IIS site.
.DESCRIPTION
- Reads environment-specific settings from a PowerShell data file profile.
- Builds with dotnet publish.
- Uses WinSCP to mirror publish output into remote directory (deletes extraneous files).
- Optionally recycles IIS app pool and runs EF migrations remotely over WinRM.
.EXAMPLE
pwsh ./scripts/deploy-ftp.ps1 -ProfilePath ./scripts/deploy-ftp.profile.psd1
#>
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
function Assert-Tool {
param([Parameter(Mandatory = $true)][string]$Name)
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
throw "Required tool '$Name' not found. Install it or update your deploy profile."
}
}
function Require-ConfigValue {
param(
[Parameter(Mandatory = $true)][hashtable]$Config,
[Parameter(Mandatory = $true)][string]$Key
)
if (-not $Config.ContainsKey($Key) -or [string]::IsNullOrWhiteSpace([string]$Config[$Key])) {
throw "Missing required deploy profile value '$Key'."
}
}
function Resolve-ProfilePath {
param(
[Parameter(Mandatory = $true)][string]$BaseDirectory,
[Parameter(Mandatory = $true)][string]$PathValue
)
$expanded = [Environment]::ExpandEnvironmentVariables($PathValue)
if ([System.IO.Path]::IsPathRooted($expanded)) {
return $expanded
}
return [System.IO.Path]::GetFullPath((Join-Path $BaseDirectory $expanded))
}
function Read-PlainOrPrompt {
param(
[string]$Value,
[Parameter(Mandatory = $true)][string]$Prompt,
[bool]$Secure = $false
)
if (-not [string]::IsNullOrWhiteSpace($Value)) {
return $Value
}
if ($Secure) {
$pwd = Read-Host -Prompt $Prompt -AsSecureString
$ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($pwd)
try {
return [Runtime.InteropServices.Marshal]::PtrToStringUni($ptr)
}
finally {
if ($ptr -ne [IntPtr]::Zero) {
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr)
}
}
}
return Read-Host -Prompt $Prompt
}
function Invoke-WinRmScript {
param(
[Parameter(Mandatory = $true)][hashtable]$Config,
[Parameter(Mandatory = $true)][string]$PasswordValue,
[Parameter(Mandatory = $true)][scriptblock]$ScriptBlock,
[object[]]$ArgumentList = @()
)
Require-ConfigValue $Config "WinRmComputer"
Require-ConfigValue $Config "WinRmCredentialUser"
$secure = ConvertTo-SecureString $PasswordValue -AsPlainText -Force
$cred = New-Object pscredential($Config.WinRmCredentialUser, $secure)
$invokeParams = @{
ComputerName = $Config.WinRmComputer
Credential = $cred
ScriptBlock = $ScriptBlock
ArgumentList = $ArgumentList
}
if ($Config.ContainsKey("UseWinRmHttps") -and [bool]$Config.UseWinRmHttps) {
$invokeParams["UseSSL"] = $true
}
if ($Config.ContainsKey("WinRmAuth") -and -not [string]::IsNullOrWhiteSpace([string]$Config.WinRmAuth)) {
$invokeParams["Authentication"] = [string]$Config.WinRmAuth
}
Invoke-Command @invokeParams
}
if (-not (Test-Path $ProfilePath)) {
throw "Deploy profile not found: $ProfilePath. Copy scripts/deploy-ftp.profile.sample.psd1 and fill environment-specific values."
}
$resolvedProfilePath = (Resolve-Path $ProfilePath).Path
$profileDirectory = Split-Path -Parent $resolvedProfilePath
$config = Import-PowerShellDataFile -Path $resolvedProfilePath
Require-ConfigValue $config "ProjectPath"
Require-ConfigValue $config "Configuration"
Require-ConfigValue $config "Runtime"
Require-ConfigValue $config "PublishDir"
Require-ConfigValue $config "WinScpPath"
Require-ConfigValue $config "RemoteDir"
$winScpSessionName = if ($config.ContainsKey("WinScpSessionName")) { [string]$config.WinScpSessionName } else { "" }
$useStoredSession = -not [string]::IsNullOrWhiteSpace($winScpSessionName)
if (-not $useStoredSession) {
Require-ConfigValue $config "FtpHost"
Require-ConfigValue $config "FtpUser"
}
$projectPath = Resolve-ProfilePath $profileDirectory ([string]$config.ProjectPath)
$publishDir = Resolve-ProfilePath $profileDirectory ([string]$config.PublishDir)
$winScpPath = Resolve-ProfilePath $profileDirectory ([string]$config.WinScpPath)
$selfContained = if ($config.ContainsKey("SelfContained")) { [bool]$config.SelfContained } else { $false }
$recycleAppPool = if ($config.ContainsKey("RecycleAppPool")) { [bool]$config.RecycleAppPool } else { $false }
$runEfMigrations = if ($config.ContainsKey("RunEfMigrations")) { [bool]$config.RunEfMigrations } else { $false }
$recycleAppPool = $recycleAppPool -and -not $SkipRecycle
$runEfMigrations = $runEfMigrations -and -not $SkipMigrations
$passwordFromEnv = $env:PICKNPLAY_FTP_PASSWORD
$passwordFromInput = if (-not [string]::IsNullOrWhiteSpace($Password)) { $Password } else { $passwordFromEnv }
$needsFtpPassword = -not $useStoredSession
$needsWinRmPassword = $recycleAppPool -or $runEfMigrations
$sharedPassword = ""
if ($needsFtpPassword -or $needsWinRmPassword) {
$prompt = if ($needsFtpPassword -and $needsWinRmPassword) { "FTP/WinRM password" } elseif ($needsFtpPassword) { "FTP password" } else { "WinRM password" }
$sharedPassword = Read-PlainOrPrompt -Value $passwordFromInput -Prompt $prompt -Secure $true
}
$passwordForSession = if ($needsFtpPassword) { $sharedPassword } else { "" }
$passwordForWinRm = if ($needsWinRmPassword) { $sharedPassword } else { "" }
Assert-Tool "dotnet"
Assert-Tool $winScpPath
Write-Host "1) Publishing..." -ForegroundColor Cyan
if (Test-Path $publishDir) {
Remove-Item $publishDir -Recurse -Force -ErrorAction SilentlyContinue
}
New-Item -ItemType Directory -Force -Path $publishDir | Out-Null
$publishArgs = @("publish", $projectPath, "-c", [string]$config.Configuration, "-r", [string]$config.Runtime, "-o", $publishDir)
if (-not $selfContained) {
$publishArgs += "--self-contained=false"
}
dotnet @publishArgs
Write-Host "2) Skipping legacy index.html app-base rewrite (Blazor frontend)." -ForegroundColor Cyan
if ($recycleAppPool) {
Require-ConfigValue $config "AppPoolName"
$appPoolName = [string]$config.AppPoolName
Write-Host "2) Stopping IIS app pool via WinRM..." -ForegroundColor Cyan
try {
Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock {
param($poolName)
Import-Module WebAdministration
Stop-WebAppPool -Name $poolName -ErrorAction SilentlyContinue
Get-Process GameList -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
Get-Process dotnet -ErrorAction SilentlyContinue | Where-Object { $_.Path -like "*picknplay*" } | Stop-Process -Force -ErrorAction SilentlyContinue
} -ArgumentList @($appPoolName)
}
catch {
Write-Warning "WinRM stop failed: $($_.Exception.Message)."
}
}
Write-Host "3) Syncing via WinSCP..." -ForegroundColor Cyan
$openCommand = if ($useStoredSession) {
"open `"$winScpSessionName`""
}
else {
$ftpUser = [Uri]::EscapeDataString([string]$config.FtpUser)
$ftpPassword = [Uri]::EscapeDataString($passwordForSession.Replace("`n", "").Replace("`r", ""))
$ftpHost = [string]$config.FtpHost
"open ftp://$ftpUser`:$ftpPassword@$ftpHost"
}
$tempScript = New-TemporaryFile
@(
"option batch continue"
"option confirm off"
$openCommand
"lcd `"$publishDir`""
"cd $([string]$config.RemoteDir)"
"synchronize remote . -delete -filemask=`"|web.config;App_Data/;logs/;GameList.Tests/`""
"exit"
) | Set-Content -Path $tempScript -Encoding UTF8
& $winScpPath "/ini=nul" "/script=$tempScript"
Remove-Item $tempScript -ErrorAction SilentlyContinue
if ($recycleAppPool) {
Write-Host "4) Starting IIS app pool via WinRM..." -ForegroundColor Cyan
try {
Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock {
param($poolName)
Import-Module WebAdministration
Start-WebAppPool -Name $poolName
} -ArgumentList @($appPoolName)
}
catch {
Write-Warning "WinRM start failed: $($_.Exception.Message)."
}
}
if ($runEfMigrations) {
Require-ConfigValue $config "RemoteSitePath"
Write-Host "5) Running EF Core migrations on remote site..." -ForegroundColor Cyan
try {
Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock {
param($sitePath)
Set-Location $sitePath
if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) {
throw "dotnet is not available on remote host."
}
dotnet ef database update --no-build
} -ArgumentList @([string]$config.RemoteSitePath)
}
catch {
Write-Warning "WinRM migrations failed: $($_.Exception.Message)."
}
}
Write-Host "Done." -ForegroundColor Green

View File

@@ -1,14 +0,0 @@
param(
[string]$ProfilePath = (Join-Path $PSScriptRoot "deploy-ftp.profile.psd1"),
[string]$Password,
[switch]$SkipRecycle,
[switch]$SkipMigrations
)
$scriptPath = Join-Path $PSScriptRoot "deploy-ftp.ps1"
& $scriptPath `
-ProfilePath $ProfilePath `
-Password $Password `
-SkipRecycle:$SkipRecycle `
-SkipMigrations:$SkipMigrations

183
scripts/deploy.ps1 Normal file
View File

@@ -0,0 +1,183 @@
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
function Require-Tool {
param(
[Parameter(Mandatory = $true)][string]$Name
)
if ($null -eq (Get-Command $Name -ErrorAction SilentlyContinue)) {
throw "Required tool not found: $Name"
}
}
function Invoke-NativeCommand {
param(
[Parameter(Mandatory = $true)][string]$Name,
[Parameter(Mandatory = $true)][scriptblock]$Action
)
Write-Host $Name
& $Action
if ($LASTEXITCODE -ne 0) {
throw "Step failed: $Name"
}
}
function ConvertTo-LinuxLineEnding {
param(
[Parameter(Mandatory = $true)][string]$Value
)
return $Value.Replace("`r`n", "`n").Replace("`r", "`n")
}
function Get-RemoteScript {
param(
[Parameter(Mandatory = $true)][string]$RemoteReleaseDir,
[Parameter(Mandatory = $true)][string]$RemoteCurrentLink,
[Parameter(Mandatory = $true)][string]$ContainerName,
[Parameter(Mandatory = $true)][string]$ImageName,
[Parameter(Mandatory = $true)][string]$ReleaseTimestamp,
[Parameter(Mandatory = $true)][string]$RemoteDataDir,
[Parameter(Mandatory = $true)][string]$ContainerPort,
[Parameter(Mandatory = $true)][string]$HostPort
)
$script = @'
set -euo pipefail
remote_release_dir='__REMOTE_RELEASE_DIR__'
remote_current_link='__REMOTE_CURRENT_LINK__'
container_name='__CONTAINER_NAME__'
image_name='__IMAGE_NAME__'
release_timestamp='__RELEASE_TIMESTAMP__'
remote_data_dir='__REMOTE_DATA_DIR__'
container_port='__CONTAINER_PORT__'
host_port='__HOST_PORT__'
previous_current_target=''
if [ -L "${remote_current_link}" ]; then
previous_current_target="$(readlink -f "${remote_current_link}")"
fi
docker build -t "${image_name}:${release_timestamp}" -t "${image_name}:latest" "${remote_release_dir}"
ln -sfn "${remote_release_dir}" "${remote_current_link}"
if docker ps -aq --filter "name=^/${container_name}$" | grep -q .; then
docker rm -f "${container_name}" >/dev/null
fi
if ! docker run -d \
--name "${container_name}" \
--restart unless-stopped \
-p "127.0.0.1:${host_port}:${container_port}" \
-e ASPNETCORE_ENVIRONMENT=Production \
-e ASPNETCORE_URLS="http://+:${container_port}" \
-e ConnectionStrings__RpgRoller="Data Source=/app/data/rpgroller.db" \
-v "${remote_data_dir}:/app/data" \
"${image_name}:${release_timestamp}" >/dev/null; then
if [ -n "${previous_current_target}" ]; then
ln -sfn "${previous_current_target}" "${remote_current_link}"
fi
exit 1
fi
'@
$script = $script.
Replace("__REMOTE_RELEASE_DIR__", $RemoteReleaseDir).
Replace("__REMOTE_CURRENT_LINK__", $RemoteCurrentLink).
Replace("__CONTAINER_NAME__", $ContainerName).
Replace("__IMAGE_NAME__", $ImageName).
Replace("__RELEASE_TIMESTAMP__", $ReleaseTimestamp).
Replace("__REMOTE_DATA_DIR__", $RemoteDataDir).
Replace("__CONTAINER_PORT__", $ContainerPort).
Replace("__HOST_PORT__", $HostPort)
return ConvertTo-LinuxLineEnding -Value $script
}
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$repoRoot = Split-Path -Parent $scriptDir
$projectPath = Join-Path $repoRoot "RpgRoller\RpgRoller.csproj"
$remoteHost = "myvserver"
$remoteRoot = "/root/docker/rpgroller"
$remoteReleasesDir = "$remoteRoot/releases"
$remoteCurrentLink = "$remoteRoot/current"
$remoteDataDir = "$remoteRoot/data"
$containerName = "rpgroller"
$imageName = "rpgroller"
$containerPort = "8080"
$hostPort = "8082"
$releaseTimestamp = (Get-Date).ToUniversalTime().ToString("yyyyMMddHHmmss")
$localStageDir = Join-Path $repoRoot "artifacts\deploy\$releaseTimestamp"
$localPublishDir = Join-Path $localStageDir "publish"
$remoteReleaseDir = "$remoteReleasesDir/$releaseTimestamp"
Write-Host "Deploying release $releaseTimestamp"
Require-Tool -Name "dotnet"
Require-Tool -Name "ssh"
Require-Tool -Name "scp"
try {
New-Item -ItemType Directory -Path $localPublishDir -Force | Out-Null
Invoke-NativeCommand -Name "1) Publishing app locally..." -Action {
dotnet publish $projectPath -c Release -o $localPublishDir
}
$dockerfile = @'
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
ENV ASPNETCORE_URLS=http://+:8080
ENV DOTNET_EnableDiagnostics=0
EXPOSE 8080
COPY publish/ ./
RUN mkdir -p /app/data
ENTRYPOINT ["dotnet", "RpgRoller.dll"]
'@
ConvertTo-LinuxLineEnding -Value $dockerfile |
Set-Content -Path (Join-Path $localStageDir "Dockerfile") -NoNewline
$remoteScript = Get-RemoteScript `
-RemoteReleaseDir $remoteReleaseDir `
-RemoteCurrentLink $remoteCurrentLink `
-ContainerName $containerName `
-ImageName $imageName `
-ReleaseTimestamp $releaseTimestamp `
-RemoteDataDir $remoteDataDir `
-ContainerPort $containerPort `
-HostPort $hostPort
ConvertTo-LinuxLineEnding -Value $remoteScript |
Set-Content -Path (Join-Path $localStageDir "deploy-remote.sh") -NoNewline
Invoke-NativeCommand -Name "2) Preparing remote release directory..." -Action {
ssh $remoteHost "mkdir -p '$remoteReleasesDir' '$remoteDataDir' && test ! -e '$remoteReleaseDir' && mkdir -p '$remoteReleaseDir'"
}
Invoke-NativeCommand -Name "3) Uploading release payload..." -Action {
Push-Location $localStageDir
try {
scp -r "Dockerfile" "deploy-remote.sh" "publish" "${remoteHost}:$remoteReleaseDir/"
}
finally {
Pop-Location
}
}
Invoke-NativeCommand -Name "4) Building image and restarting container on remote host..." -Action {
ssh $remoteHost "bash '$remoteReleaseDir/deploy-remote.sh'"
}
Write-Host "5) Deployment complete."
}
finally {
if (Test-Path $localStageDir) {
Remove-Item -Path $localStageDir -Recurse -Force -ErrorAction SilentlyContinue
}
}