Compare commits
9 Commits
e7ae0e00c1
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c99544baf | |||
| 8e730af85d | |||
| 66607e51eb | |||
| ecc799ae7f | |||
| ff28f70b51 | |||
| 20c8868744 | |||
| b80e9f1aec | |||
| d74f8a65a9 | |||
| c79bea86b6 |
@@ -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
|
||||
|
||||
|
||||
27
AGENTS.md
27
AGENTS.md
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
419
POSTMORTEM.md
419
POSTMORTEM.md
@@ -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 app’s 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
17
RpgRoller/Domain/ThemePreferences.cs
Normal file
17
RpgRoller/Domain/ThemePreferences.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
273
RpgRoller/Migrations/20260518183838_AddUserThemePreference.Designer.cs
generated
Normal file
273
RpgRoller/Migrations/20260518183838_AddUserThemePreference.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
BIN
RpgRoller/wwwroot/images/dark.webp
Normal file
BIN
RpgRoller/wwwroot/images/dark.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
BIN
RpgRoller/wwwroot/images/light.webp
Normal file
BIN
RpgRoller/wwwroot/images/light.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 258 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.2 MiB |
@@ -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,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
:root {
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg-top: #f7f0d8;
|
||||
--bg-bottom: #ecdfc4;
|
||||
--button-hover: #dccfb4;
|
||||
@@ -14,6 +15,147 @@
|
||||
--public: #2d6645;
|
||||
--private-self: #4f3a8f;
|
||||
--private-gm: #915119;
|
||||
--page-background: url("/images/light.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);
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -28,7 +170,7 @@ body {
|
||||
}
|
||||
|
||||
html {
|
||||
background-image: url("/images/rpg.png");
|
||||
background-image: var(--page-background);
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
@@ -100,7 +242,7 @@ h3 {
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
background: linear-gradient(120deg, #f1e4c9, #efe0bf);
|
||||
background: var(--header-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 0.8rem;
|
||||
padding: 0.5rem 0.7rem;
|
||||
@@ -120,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 {
|
||||
@@ -156,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));
|
||||
@@ -176,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;
|
||||
}
|
||||
@@ -196,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 {
|
||||
@@ -294,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 {
|
||||
@@ -309,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;
|
||||
}
|
||||
@@ -320,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;
|
||||
@@ -381,7 +556,7 @@ select:focus-visible {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background-color: #f8f0de;
|
||||
background-color: var(--skill-group-bg);
|
||||
padding: 0.1rem;
|
||||
border-top: 1px solid var(--card-border);
|
||||
gap: 0.5rem;
|
||||
@@ -424,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);
|
||||
}
|
||||
|
||||
@@ -436,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;
|
||||
@@ -487,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;
|
||||
@@ -504,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 {
|
||||
@@ -517,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 {
|
||||
@@ -533,7 +708,7 @@ select:focus-visible {
|
||||
}
|
||||
|
||||
.logout-link:hover {
|
||||
color: #6b2419;
|
||||
color: var(--accent-2-hover);
|
||||
}
|
||||
|
||||
.logout-link:focus-visible {
|
||||
@@ -541,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;
|
||||
@@ -564,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;
|
||||
@@ -575,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;
|
||||
}
|
||||
|
||||
@@ -613,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,
|
||||
@@ -643,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 {
|
||||
@@ -679,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) {
|
||||
@@ -694,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 {
|
||||
@@ -706,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;
|
||||
}
|
||||
|
||||
@@ -729,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 {
|
||||
@@ -753,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 {
|
||||
@@ -822,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 {
|
||||
@@ -852,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;
|
||||
}
|
||||
|
||||
@@ -871,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%);
|
||||
}
|
||||
@@ -960,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;
|
||||
}
|
||||
@@ -970,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;
|
||||
@@ -993,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;
|
||||
}
|
||||
@@ -1014,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;
|
||||
@@ -1036,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;
|
||||
@@ -1055,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;
|
||||
@@ -1085,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);
|
||||
}
|
||||
|
||||
@@ -1103,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);
|
||||
}
|
||||
|
||||
@@ -1113,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 {
|
||||
@@ -1180,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;
|
||||
}
|
||||
|
||||
17
deploy.ps1
17
deploy.ps1
@@ -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
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
183
scripts/deploy.ps1
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user