From def2a3f680cc7b287b832ffbd0f4b02bdc2426b4 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 4 May 2026 21:23:45 +0200 Subject: [PATCH] Implement milestone 2 route navigation --- README.md | 20 ++-- RpgRoller.Tests/Api/FrontendHostTests.cs | 16 +++ .../Services/WorkspaceStateTests.cs | 15 +-- RpgRoller/Components/Pages/AdminPage.razor | 3 + RpgRoller/Components/Pages/AdminPage.razor.cs | 8 ++ .../Components/Pages/AuthenticatedPageBase.cs | 29 +++++ .../Components/Pages/CampaignsPage.razor | 3 + .../Components/Pages/CampaignsPage.razor.cs | 8 ++ RpgRoller/Components/Pages/PlayPage.razor | 3 +- RpgRoller/Components/Pages/PlayPage.razor.cs | 21 ---- RpgRoller/Components/Pages/Workspace.razor | 8 +- RpgRoller/Components/Pages/Workspace.razor.cs | 54 ++++++--- .../WorkspaceCampaignScopeCoordinator.cs | 24 +++- .../Pages/WorkspaceLiveStateController.cs | 29 +++-- .../Pages/WorkspacePlayCoordinator.cs | 46 +++++--- RpgRoller/Components/Pages/WorkspaceRoute.cs | 8 ++ .../Pages/WorkspaceSessionCoordinator.cs | 105 +++++------------- RpgRoller/Components/Pages/WorkspaceState.cs | 46 ++++---- TASKS.md | 24 ++-- tests/e2e/lib/selenium-smoke.js | 2 +- tests/e2e/smoke.js | 55 +++++++++ 21 files changed, 334 insertions(+), 193 deletions(-) create mode 100644 RpgRoller/Components/Pages/AdminPage.razor create mode 100644 RpgRoller/Components/Pages/AdminPage.razor.cs create mode 100644 RpgRoller/Components/Pages/AuthenticatedPageBase.cs create mode 100644 RpgRoller/Components/Pages/CampaignsPage.razor create mode 100644 RpgRoller/Components/Pages/CampaignsPage.razor.cs create mode 100644 RpgRoller/Components/Pages/WorkspaceRoute.cs diff --git a/README.md b/README.md index 5484877..b4fe76b 100644 --- a/README.md +++ b/README.md @@ -39,13 +39,14 @@ Frontend: - `RpgRoller/Components/App.razor`: current HTML shell and the request-time branch that decides whether `/` serves the static auth page or the interactive app - `RpgRoller/Components/Routes.razor`: Blazor router and layout hookup - `RpgRoller/Components/Layout/MainLayout.razor`: default layout -- `RpgRoller/Components/Pages/Home.razor`: current root route component for `/`; it only renders `Workspace` -- `RpgRoller/Components/Pages/Home.razor.cs`: logout navigation helper that force-loads `/` and carries auth status query text +- `RpgRoller/Components/Pages/LoginPage.razor`: route marker for the static `/login` auth document +- `RpgRoller/Components/Pages/PlayPage.razor`, `CampaignsPage.razor`, and `AdminPage.razor`: authenticated route entry points for the interactive workspace +- `RpgRoller/Components/Pages/AuthenticatedPageBase.cs`: shared logout-to-`/login` redirect helper for authenticated route pages - `RpgRoller/Components/Pages/Workspace.razor`: authenticated workspace UI with play, campaign management, admin, toasts, and modals - `RpgRoller/Components/Pages/Workspace.razor.cs`: workspace composition root, lifecycle, coordinator wiring, JS-invokable entry points, and menu item construction - `RpgRoller/Components/Pages/WorkspaceState.cs`: workspace UI state plus pure computed and formatting projections used directly by the Razor view - `RpgRoller/Components/Pages/WorkspaceSessionCoordinator.cs`, `WorkspaceCampaignCoordinator.cs`, `WorkspaceCampaignScopeCoordinator.cs`, `WorkspacePlayCoordinator.cs`, `WorkspaceAdminCoordinator.cs`, `WorkspaceLiveStateController.cs`, `WorkspaceFeedbackService.cs`, and `WorkspaceToast.cs`: session bootstrap, campaign scope, play and log, admin, live update, and toast concerns used by `Workspace` -- `RpgRoller/Components/Pages/HomeControls/StaticAuthPage.razor`: plain HTML login and registration page used when `/` is requested without a valid session +- `RpgRoller/Components/Pages/HomeControls/StaticAuthPage.razor`: plain HTML login and registration page used at `/login` - `RpgRoller/Components/Pages/HomeControls/`: workspace child components, forms, header, panels, and modal controls - `RpgRoller/Components/RpgRollerApiClient.cs`: browser API client for write actions - `RpgRoller/Components/WorkspaceQueryService.cs`: browser-facing read client for workspace data @@ -96,19 +97,20 @@ Rolemaster support: The current frontend is in an intermediate state that was created while mitigating the Firefox and RoboForm failure documented in `POSTMORTEM.md`. -Today, `/` is dual-purpose: +Today, `/` is an auth-aware entry redirect: -- when the request has no valid session cookie, `RpgRoller/Components/App.razor` renders `StaticAuthPage.razor` as plain HTML and `RpgRoller/wwwroot/js/rpgroller-api.js` handles login and registration through `fetch` -- when the request has a valid session cookie, `App.razor` renders the interactive Blazor app and `Home.razor` loads the authenticated `Workspace` +- anonymous `GET /` redirects to `/login` +- authenticated `GET /` redirects to `/play` +- `RpgRoller/Components/App.razor` still decides between the static `/login` document and the interactive route set based on the request path, not auth state -Inside the authenticated app, the hamburger menu does not navigate to different URLs. Instead, `WorkspaceSessionCoordinator.cs` stores a `screen` preference in `sessionStorage`, and `Workspace.razor` conditionally swaps between play, campaign management, and admin screens inside one large component tree. +Inside the authenticated app, `/play`, `/campaigns`, and `/admin` are now real Blazor routes, and the hamburger menu navigates between those URLs. The interactive shell is still structurally transitional, because `Workspace.razor` continues to own all three major authenticated subtrees behind one component. This architecture works functionally but remains structurally fragile because: -- the root shell still branches on request-time `HttpContext` +- the HTML shell still branches on request path to keep `/login` static - the authenticated workspace still performs staged startup in `OnAfterRenderAsync` - the app coordinates state across Blazor component state, browser `sessionStorage`, `fetch`, and SSE during early startup -- the route URL does not represent the authenticated screen the user is actually viewing +- the shared `Workspace` component still conditionally renders play, campaign management, and admin DOM instead of letting each route own its own subtree ## Approved Rewrite Direction diff --git a/RpgRoller.Tests/Api/FrontendHostTests.cs b/RpgRoller.Tests/Api/FrontendHostTests.cs index d828c1d..1935e1a 100644 --- a/RpgRoller.Tests/Api/FrontendHostTests.cs +++ b/RpgRoller.Tests/Api/FrontendHostTests.cs @@ -40,4 +40,20 @@ public sealed class FrontendHostTests(WebApplicationFactory factory) : Assert.Contains("data-auth-page", html); Assert.DoesNotContain("_framework/blazor.web.js", html); } + + [Theory] + [InlineData("/play")] + [InlineData("/campaigns")] + [InlineData("/admin")] + public async Task AuthenticatedRoutes_ServeInteractiveShell(string path) + { + using var factory = CreateFactory(1); + using var client = factory.CreateClient(new() { AllowAutoRedirect = false }); + var response = await client.GetAsync(path); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var html = await response.Content.ReadAsStringAsync(); + Assert.Contains("_framework/blazor.web.js", html); + Assert.DoesNotContain("data-auth-page", html); + } } \ No newline at end of file diff --git a/RpgRoller.Tests/Services/WorkspaceStateTests.cs b/RpgRoller.Tests/Services/WorkspaceStateTests.cs index cd19652..3234c0d 100644 --- a/RpgRoller.Tests/Services/WorkspaceStateTests.cs +++ b/RpgRoller.Tests/Services/WorkspaceStateTests.cs @@ -28,7 +28,8 @@ public sealed class WorkspaceStateTests public void SkillDefinitionLabel_FormatsD6RolemasterAndDefaultRulesets() { var skill = new CharacterSheetSkill(Guid.NewGuid(), null, "Awareness", "d100!+15", 1, true, 5, true); - var state = new WorkspaceState { SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(Guid.NewGuid(), "GM"), []) }; + var state = new WorkspaceState + { SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(Guid.NewGuid(), "GM"), []) }; Assert.Equal("d100!+15, wild 1, fumble on", state.SkillDefinitionLabel(skill)); @@ -49,7 +50,8 @@ public sealed class WorkspaceStateTests var state = new WorkspaceState { User = new(userId, "user", "User", []), - SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(Guid.NewGuid(), "GM"), [ownedCharacter, secondOwnedCharacter, otherCharacter]), + SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(Guid.NewGuid(), "GM"), + [ownedCharacter, secondOwnedCharacter, otherCharacter]), SelectedCharacterId = secondOwnedCharacter.Id, ActiveCharacterId = ownedCharacter.Id, SelectedCharacterSkills = [new(Guid.NewGuid(), null, "Stealth", "2D+1", 1, true, null, false)], @@ -69,33 +71,26 @@ public sealed class WorkspaceStateTests } [Fact] - public void ScreenAndConnectionFlags_ReflectCurrentState() + public void CampaignAndConnectionFlags_ReflectCurrentState() { var adminId = Guid.NewGuid(); var state = new WorkspaceState { User = new(adminId, "admin", "Admin", [UserRoles.Admin]), SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(adminId, "Admin"), []), - CurrentScreen = "admin", ConnectionState = "reconnecting" }; - Assert.True(state.IsAdminScreen); - Assert.False(state.IsPlayScreen); Assert.True(state.IsCurrentUserAdmin); Assert.True(state.IsCurrentUserGm); Assert.True(state.CanDeleteSelectedCampaign); Assert.True(state.IsSelectedCampaignD6); Assert.Equal("Reconnecting", state.ConnectionStateLabel); Assert.Equal("warn", state.ConnectionStateCssClass); - Assert.Equal("rr-app", state.AppCssClass); - state.CurrentScreen = "play"; state.ConnectionState = "connected"; - Assert.True(state.IsPlayScreen); Assert.Equal("Connected", state.ConnectionStateLabel); Assert.Equal("ok", state.ConnectionStateCssClass); - Assert.Equal("rr-app app-play", state.AppCssClass); } } \ No newline at end of file diff --git a/RpgRoller/Components/Pages/AdminPage.razor b/RpgRoller/Components/Pages/AdminPage.razor new file mode 100644 index 0000000..bb32e53 --- /dev/null +++ b/RpgRoller/Components/Pages/AdminPage.razor @@ -0,0 +1,3 @@ +@page "/admin" +@inherits AuthenticatedPageBase + diff --git a/RpgRoller/Components/Pages/AdminPage.razor.cs b/RpgRoller/Components/Pages/AdminPage.razor.cs new file mode 100644 index 0000000..e892608 --- /dev/null +++ b/RpgRoller/Components/Pages/AdminPage.razor.cs @@ -0,0 +1,8 @@ +using System.Diagnostics.CodeAnalysis; + +namespace RpgRoller.Components.Pages; + +[ExcludeFromCodeCoverage] +public partial class AdminPage +{ +} \ No newline at end of file diff --git a/RpgRoller/Components/Pages/AuthenticatedPageBase.cs b/RpgRoller/Components/Pages/AuthenticatedPageBase.cs new file mode 100644 index 0000000..01a524d --- /dev/null +++ b/RpgRoller/Components/Pages/AuthenticatedPageBase.cs @@ -0,0 +1,29 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.WebUtilities; + +namespace RpgRoller.Components.Pages; + +[ExcludeFromCodeCoverage] +public abstract class AuthenticatedPageBase : ComponentBase +{ + protected Task OnLoggedOutAsync(string? message) + { + if (string.IsNullOrWhiteSpace(message)) + { + Navigation.NavigateTo("/login", forceLoad: true); + return Task.CompletedTask; + } + + var query = new Dictionary + { + ["message"] = message, + ["kind"] = message.Contains("expired", StringComparison.OrdinalIgnoreCase) ? "error" : "success" + }; + + Navigation.NavigateTo(QueryHelpers.AddQueryString("/login", query), forceLoad: true); + return Task.CompletedTask; + } + + [Inject] protected NavigationManager Navigation { get; set; } = null!; +} \ No newline at end of file diff --git a/RpgRoller/Components/Pages/CampaignsPage.razor b/RpgRoller/Components/Pages/CampaignsPage.razor new file mode 100644 index 0000000..f55bdfe --- /dev/null +++ b/RpgRoller/Components/Pages/CampaignsPage.razor @@ -0,0 +1,3 @@ +@page "/campaigns" +@inherits AuthenticatedPageBase + diff --git a/RpgRoller/Components/Pages/CampaignsPage.razor.cs b/RpgRoller/Components/Pages/CampaignsPage.razor.cs new file mode 100644 index 0000000..989dbe1 --- /dev/null +++ b/RpgRoller/Components/Pages/CampaignsPage.razor.cs @@ -0,0 +1,8 @@ +using System.Diagnostics.CodeAnalysis; + +namespace RpgRoller.Components.Pages; + +[ExcludeFromCodeCoverage] +public partial class CampaignsPage +{ +} \ No newline at end of file diff --git a/RpgRoller/Components/Pages/PlayPage.razor b/RpgRoller/Components/Pages/PlayPage.razor index 8104a95..de99433 100644 --- a/RpgRoller/Components/Pages/PlayPage.razor +++ b/RpgRoller/Components/Pages/PlayPage.razor @@ -1,2 +1,3 @@ @page "/play" - +@inherits AuthenticatedPageBase + diff --git a/RpgRoller/Components/Pages/PlayPage.razor.cs b/RpgRoller/Components/Pages/PlayPage.razor.cs index 98db3dc..9966f01 100644 --- a/RpgRoller/Components/Pages/PlayPage.razor.cs +++ b/RpgRoller/Components/Pages/PlayPage.razor.cs @@ -1,29 +1,8 @@ using System.Diagnostics.CodeAnalysis; -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.WebUtilities; namespace RpgRoller.Components.Pages; [ExcludeFromCodeCoverage] public partial class PlayPage { - private Task OnLoggedOutAsync(string? message) - { - if (string.IsNullOrWhiteSpace(message)) - { - Navigation.NavigateTo("/login", forceLoad: true); - return Task.CompletedTask; - } - - var query = new Dictionary - { - ["message"] = message, - ["kind"] = message.Contains("expired", StringComparison.OrdinalIgnoreCase) ? "error" : "success" - }; - - Navigation.NavigateTo(QueryHelpers.AddQueryString("/login", query), forceLoad: true); - return Task.CompletedTask; - } - - [Inject] private NavigationManager Navigation { get; set; } = null!; } \ No newline at end of file diff --git a/RpgRoller/Components/Pages/Workspace.razor b/RpgRoller/Components/Pages/Workspace.razor index 643966b..d786f61 100644 --- a/RpgRoller/Components/Pages/Workspace.razor +++ b/RpgRoller/Components/Pages/Workspace.razor @@ -1,5 +1,5 @@ @using RpgRoller.Components.Pages.HomeControls -
+

@State.LiveAnnouncement

@if (State.HasHealthIssue) @@ -28,7 +28,7 @@ ToggleMenuRequested="ToggleScreenMenu" LogoutRequested="Session.LogoutAsync"/> - @if (State.IsPlayScreen) + @if (IsPlayRoute) {
} - else if (State.IsManagementScreen) + else if (IsCampaignsRoute) { } - else if (State.IsAdminScreen) + else if (IsAdminRoute) {
@if (State.IsCurrentUserAdmin) diff --git a/RpgRoller/Components/Pages/Workspace.razor.cs b/RpgRoller/Components/Pages/Workspace.razor.cs index 91ddbfc..b23f718 100644 --- a/RpgRoller/Components/Pages/Workspace.razor.cs +++ b/RpgRoller/Components/Pages/Workspace.razor.cs @@ -9,6 +9,11 @@ namespace RpgRoller.Components.Pages; [ExcludeFromCodeCoverage] public partial class Workspace : IAsyncDisposable { + protected override void OnParametersSet() + { + State.IsScreenMenuOpen = false; + } + protected override async Task OnAfterRenderAsync(bool firstRender) { State.HasInteractiveRenderStarted = true; @@ -100,6 +105,22 @@ public partial class Workspace : IAsyncDisposable State.IsScreenMenuOpen = !State.IsScreenMenuOpen; } + private Task NavigateToRouteAsync(string route) + { + State.IsScreenMenuOpen = false; + Navigation.NavigateTo(route); + return InvokeAsync(StateHasChanged); + } + + private Task RedirectToPlayAsync() + { + if (IsPlayRoute) + return Task.CompletedTask; + + Navigation.NavigateTo("/play"); + return Task.CompletedTask; + } + private static bool IsStaticRenderInteropException(InvalidOperationException exception) { return exception.Message.Contains("statically rendered", StringComparison.OrdinalIgnoreCase); @@ -114,22 +135,30 @@ public partial class Workspace : IAsyncDisposable [Inject] private NavigationManager Navigation { get; set; } = null!; [Parameter] public EventCallback LoggedOut { get; set; } + [Parameter] public WorkspaceRoute Route { get; set; } = WorkspaceRoute.Play; private WorkspaceState State { get; } = new(); private bool HasSessionInitialized { get; set; } private bool EnableCharacterControls { get; set; } private bool EnableCustomRollComposer { get; set; } + private bool IsPlayRoute => Route == WorkspaceRoute.Play; + private bool IsCampaignsRoute => Route == WorkspaceRoute.Campaigns; + private bool IsAdminRoute => Route == WorkspaceRoute.Admin; + private string AppCssClass => IsPlayRoute ? "rr-app app-play" : "rr-app"; private WorkspaceCampaignScopeCoordinator Scope => m_Scope ??= new(State, Feedback, JS, WorkspaceQuery, - Play.EnsureSelectedCharacterActiveAsync, Play.RefreshSelectedCharacterSheetAsync, Play.RefreshCampaignLogAsync, - Play.ResetCampaignLogDetailState, Play.ResetCampaignStateTracking, ClearAuthenticatedState, + () => IsPlayRoute, Play.EnsureSelectedCharacterActiveAsync, Play.RefreshSelectedCharacterSheetAsync, + Play.RefreshCampaignLogAsync, Play.ResetCampaignLogDetailState, Play.ResetCampaignStateTracking, + ClearAuthenticatedState, StopStateEventsAsync, message => LoggedOut.InvokeAsync(message)); - private WorkspaceLiveStateController Live => m_Live ??= new(State, Feedback, StartStateEventsCoreAsync, + private WorkspaceLiveStateController Live => m_Live ??= new(State, Feedback, () => IsPlayRoute, () => IsAdminRoute, + StartStateEventsCoreAsync, StopStateEventsCoreAsync, Scope.RefreshCampaignRosterAsync, Play.RefreshSelectedCharacterSheetAsync, Play.RefreshCampaignLogAsync, () => InvokeAsync(StateHasChanged)); - private WorkspacePlayCoordinator Play => m_Play ??= new(State, Feedback, ApiClient, WorkspaceQuery, + private WorkspacePlayCoordinator Play => m_Play ??= new(State, Feedback, () => IsPlayRoute, ApiClient, + WorkspaceQuery, CanEditCharacter, () => InvokeAsync(StateHasChanged)); private WorkspaceCampaignCoordinator Campaigns => m_Campaigns ??= new(State, Feedback, JS, ApiClient, @@ -142,10 +171,10 @@ public partial class Workspace : IAsyncDisposable private WorkspaceFeedbackService Feedback => m_Feedback ??= new(State, () => InvokeAsync(StateHasChanged)); private WorkspaceSessionCoordinator Session => m_Session ??= new(State, Feedback, JS, ApiClient, WorkspaceQuery, + () => IsAdminRoute, RedirectToPlayAsync, Scope.ReloadCampaignsAsync, Scope.ReloadCharacterCampaignOptionsAsync, Scope.RefreshCampaignScopeAsync, Live.SyncStateEventsAsync, Live.StopStateEventsAsync, EnsureAdminUsersLoadedAsync, - Play.ResetCampaignLogDetailState, () => InvokeAsync(StateHasChanged), - message => LoggedOut.InvokeAsync(message)); + Play.ResetCampaignLogDetailState, message => LoggedOut.InvokeAsync(message)); private IReadOnlyList HeaderMenuItems { @@ -156,14 +185,14 @@ public partial class Workspace : IAsyncDisposable new() { Label = "Play", - IsActive = State.IsPlayScreen, - OnSelected = () => Session.SwitchScreenAsync("play") + IsActive = IsPlayRoute, + OnSelected = () => NavigateToRouteAsync("/play") }, new() { Label = "Campaign Management", - IsActive = State.IsManagementScreen, - OnSelected = () => Session.SwitchScreenAsync("management") + IsActive = IsCampaignsRoute, + OnSelected = () => NavigateToRouteAsync("/campaigns") } }; @@ -172,8 +201,8 @@ public partial class Workspace : IAsyncDisposable items.Add(new() { Label = "Admin", - IsActive = State.IsAdminScreen, - OnSelected = () => Session.SwitchScreenAsync(ScreenAdmin) + IsActive = IsAdminRoute, + OnSelected = () => NavigateToRouteAsync("/admin") }); } @@ -184,7 +213,6 @@ public partial class Workspace : IAsyncDisposable private string AdminDatabaseDownloadUrl => Navigation.ToAbsoluteUri("api/admin/database").ToString(); private DotNetObjectReference? DotNetRef { get; set; } - private const string ScreenAdmin = "admin"; private WorkspaceAdminCoordinator? m_Admin; private WorkspaceCampaignCoordinator? m_Campaigns; private WorkspaceFeedbackService? m_Feedback; diff --git a/RpgRoller/Components/Pages/WorkspaceCampaignScopeCoordinator.cs b/RpgRoller/Components/Pages/WorkspaceCampaignScopeCoordinator.cs index e09e2f1..a46ff5a 100644 --- a/RpgRoller/Components/Pages/WorkspaceCampaignScopeCoordinator.cs +++ b/RpgRoller/Components/Pages/WorkspaceCampaignScopeCoordinator.cs @@ -4,7 +4,20 @@ using Microsoft.JSInterop; namespace RpgRoller.Components.Pages; [ExcludeFromCodeCoverage] -public sealed class WorkspaceCampaignScopeCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, IJSRuntime js, WorkspaceQueryService workspaceQuery, Func ensureSelectedCharacterActiveAsync, Func refreshSelectedCharacterSheetAsync, Func refreshCampaignLogAsync, Action resetCampaignLogDetailState, Action resetCampaignStateTracking, Action clearAuthenticatedState, Func stopStateEventsAsync, Func onLoggedOutAsync) +public sealed class WorkspaceCampaignScopeCoordinator( + WorkspaceState state, + WorkspaceFeedbackService feedback, + IJSRuntime js, + WorkspaceQueryService workspaceQuery, + Func isPlayRoute, + Func ensureSelectedCharacterActiveAsync, + Func refreshSelectedCharacterSheetAsync, + Func refreshCampaignLogAsync, + Action resetCampaignLogDetailState, + Action resetCampaignStateTracking, + Action clearAuthenticatedState, + Func stopStateEventsAsync, + Func onLoggedOutAsync) { public async Task ReloadCampaignsAsync(Guid? preferredCampaignId) { @@ -24,13 +37,15 @@ public sealed class WorkspaceCampaignScopeCoordinator(WorkspaceState state, Work else if (!state.SelectedCampaignId.HasValue || !campaignIds.Contains(state.SelectedCampaignId.Value)) state.SelectedCampaignId = state.Campaigns[0].Id; - await js.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, state.SelectedCampaignId?.ToString()); + await js.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, + state.SelectedCampaignId?.ToString()); } public async Task ReloadCharacterCampaignOptionsAsync() { var campaignOptions = await workspaceQuery.GetCharacterCampaignOptionsAsync(); - state.CharacterCampaignOptions = campaignOptions.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase).ToList(); + state.CharacterCampaignOptions = + campaignOptions.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase).ToList(); } public async Task RefreshCampaignRosterAsync() @@ -45,7 +60,8 @@ public sealed class WorkspaceCampaignScopeCoordinator(WorkspaceState state, Work state.SelectedCampaign = await workspaceQuery.GetCampaignAsync(state.SelectedCampaignId.Value); SyncSelectedCharacter(); - if (state.IsPlayScreen && state.PlaySelectedCharacterId.HasValue && state.SelectedCharacterId != state.PlaySelectedCharacterId) + if (isPlayRoute() && state.PlaySelectedCharacterId.HasValue && + state.SelectedCharacterId != state.PlaySelectedCharacterId) state.SelectedCharacterId = state.PlaySelectedCharacterId; await ensureSelectedCharacterActiveAsync(); diff --git a/RpgRoller/Components/Pages/WorkspaceLiveStateController.cs b/RpgRoller/Components/Pages/WorkspaceLiveStateController.cs index 844009b..3162e62 100644 --- a/RpgRoller/Components/Pages/WorkspaceLiveStateController.cs +++ b/RpgRoller/Components/Pages/WorkspaceLiveStateController.cs @@ -4,7 +4,17 @@ using RpgRoller.Contracts; namespace RpgRoller.Components.Pages; [ExcludeFromCodeCoverage] -public sealed class WorkspaceLiveStateController(WorkspaceState state, WorkspaceFeedbackService feedback, Func startStateEventsAsync, Func stopStateEventsCoreAsync, Func refreshCampaignRosterAsync, Func refreshSelectedCharacterSheetAsync, Func refreshCampaignLogAsync, Func requestRefreshAsync) +public sealed class WorkspaceLiveStateController( + WorkspaceState state, + WorkspaceFeedbackService feedback, + Func isPlayRoute, + Func isAdminRoute, + Func startStateEventsAsync, + Func stopStateEventsCoreAsync, + Func refreshCampaignRosterAsync, + Func refreshSelectedCharacterSheetAsync, + Func refreshCampaignLogAsync, + Func requestRefreshAsync) { public async Task OnStateEventReceivedAsync(CampaignStateSnapshot state1) { @@ -27,15 +37,17 @@ public sealed class WorkspaceLiveStateController(WorkspaceState state, Workspace var previousSelectedCharacterId = state.SelectedCharacterId; var previousSelectedCharacterVersion = GetCharacterVersion(previousState, previousSelectedCharacterId); var rosterChanged = state1.RosterVersion != previousState.RosterVersion; - var logChanged = state.IsPlayScreen && state1.LogVersion != previousState.LogVersion; + var logChanged = isPlayRoute() && state1.LogVersion != previousState.LogVersion; if (rosterChanged) await refreshCampaignRosterAsync(); var selectedCharacterChanged = previousSelectedCharacterId != state.SelectedCharacterId; - var selectedCharacterVersionChanged = state.IsPlayScreen && !selectedCharacterChanged && GetCharacterVersion(state1, state.SelectedCharacterId) != previousSelectedCharacterVersion; + var selectedCharacterVersionChanged = isPlayRoute() && !selectedCharacterChanged && + GetCharacterVersion(state1, state.SelectedCharacterId) != + previousSelectedCharacterVersion; - if (state.IsPlayScreen && (selectedCharacterChanged || selectedCharacterVersionChanged)) + if (isPlayRoute() && (selectedCharacterChanged || selectedCharacterVersionChanged)) await refreshSelectedCharacterSheetAsync(); if (logChanged) @@ -54,9 +66,9 @@ public sealed class WorkspaceLiveStateController(WorkspaceState state, Workspace { state.ConnectionState = state1 switch { - "connected" => "connected", + "connected" => "connected", "reconnecting" => "reconnecting", - _ => "offline" + _ => "offline" }; if (state.ConnectionState == "reconnecting") @@ -70,7 +82,7 @@ public sealed class WorkspaceLiveStateController(WorkspaceState state, Workspace public async Task SyncStateEventsAsync() { - if (state.User is null || !state.SelectedCampaignId.HasValue || state.IsAdminScreen) + if (state.User is null || !state.SelectedCampaignId.HasValue || isAdminRoute()) { await StopStateEventsAsync(); state.ConnectionState = "offline"; @@ -94,6 +106,7 @@ public sealed class WorkspaceLiveStateController(WorkspaceState state, Workspace if (!characterId.HasValue) return 0; - return snapshot.CharacterVersions.FirstOrDefault(version => version.CharacterId == characterId.Value)?.Version ?? 0; + return snapshot.CharacterVersions.FirstOrDefault(version => version.CharacterId == characterId.Value) + ?.Version ?? 0; } } \ No newline at end of file diff --git a/RpgRoller/Components/Pages/WorkspacePlayCoordinator.cs b/RpgRoller/Components/Pages/WorkspacePlayCoordinator.cs index 2f5b73b..54b85cd 100644 --- a/RpgRoller/Components/Pages/WorkspacePlayCoordinator.cs +++ b/RpgRoller/Components/Pages/WorkspacePlayCoordinator.cs @@ -5,11 +5,18 @@ using RpgRoller.Contracts; namespace RpgRoller.Components.Pages; [ExcludeFromCodeCoverage] -public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, RpgRollerApiClient apiClient, WorkspaceQueryService workspaceQuery, Func canEditCharacter, Func requestRefreshAsync) +public sealed class WorkspacePlayCoordinator( + WorkspaceState state, + WorkspaceFeedbackService feedback, + Func isPlayRoute, + RpgRollerApiClient apiClient, + WorkspaceQueryService workspaceQuery, + Func canEditCharacter, + Func requestRefreshAsync) { public async Task RefreshCampaignLogAsync(Guid? afterRollId = null) { - if (!state.SelectedCampaignId.HasValue || !state.IsPlayScreen) + if (!state.SelectedCampaignId.HasValue || !isPlayRoute()) { state.CampaignLog = []; state.CampaignLogCursor = null; @@ -18,7 +25,8 @@ public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeed } var previousLogCount = state.CampaignLog.Count; - var page = await workspaceQuery.GetCampaignLogPageAsync(state.SelectedCampaignId.Value, afterRollId, CampaignLogWindowSize); + var page = await workspaceQuery.GetCampaignLogPageAsync(state.SelectedCampaignId.Value, afterRollId, + CampaignLogWindowSize); Guid? newestRollId = null; if (!afterRollId.HasValue || page.ResetRequired) state.CampaignLog = page.Entries.ToList(); @@ -30,7 +38,8 @@ public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeed } var shouldAutoExpandNewest = afterRollId.HasValue && page.Entries.Length > 0; - if (!shouldAutoExpandNewest && !afterRollId.HasValue && state.CurrentCampaignState is not null && previousLogCount == 0 && page.Entries.Length > 0) + if (!shouldAutoExpandNewest && !afterRollId.HasValue && state.CurrentCampaignState is not null && + previousLogCount == 0 && page.Entries.Length > 0) shouldAutoExpandNewest = true; if (shouldAutoExpandNewest) @@ -58,7 +67,7 @@ public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeed public async Task RefreshSelectedCharacterSheetAsync() { - if (!state.SelectedCharacterId.HasValue || state.SelectedCampaign is null || !state.IsPlayScreen) + if (!state.SelectedCharacterId.HasValue || state.SelectedCampaign is null || !isPlayRoute()) { state.SelectedCharacterSkills = []; state.SelectedCharacterSkillGroups = []; @@ -66,8 +75,10 @@ public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeed } var sheet = await workspaceQuery.GetCharacterSheetAsync(state.SelectedCharacterId.Value); - state.SelectedCharacterSkillGroups = sheet.SkillGroups.OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase).ToList(); - state.SelectedCharacterSkills = sheet.Skills.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase).ToList(); + state.SelectedCharacterSkillGroups = + sheet.SkillGroups.OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase).ToList(); + state.SelectedCharacterSkills = + sheet.Skills.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase).ToList(); } public Task EnsureSelectedCharacterActiveAsync() @@ -152,7 +163,8 @@ public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeed return Task.CompletedTask; } - if (string.Equals(state.SelectedCampaign.RulesetId, RulesetFormHelpers.RulesetIds.Rolemaster, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(state.SelectedCampaign.RulesetId, RulesetFormHelpers.RulesetIds.Rolemaster, + StringComparison.OrdinalIgnoreCase)) { OpenRolemasterSkillRollModal(skill); return Task.CompletedTask; @@ -177,7 +189,8 @@ public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeed state.IsSubmittingRolemasterSkillRoll = true; try { - await ExecuteSkillRollAsync(state.PendingRolemasterSkillRoll.Id, situationalModifier, keepModalOpenOnError: true); + await ExecuteSkillRollAsync(state.PendingRolemasterSkillRoll.Id, situationalModifier, + keepModalOpenOnError: true); } finally { @@ -199,7 +212,8 @@ public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeed state.IsMutating = true; try { - var roll = await apiClient.RequestAsync("POST", $"/api/skills/{skillId}/roll", new RollSkillRequest(state.RollVisibility, situationalModifier)); + var roll = await apiClient.RequestAsync("POST", $"/api/skills/{skillId}/roll", + new RollSkillRequest(state.RollVisibility, situationalModifier)); CloseRolemasterSkillRollModal(); await HandleRecordedRollAsync(roll); } @@ -314,7 +328,8 @@ public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeed return; var character = state.SelectedCampaign.Characters.FirstOrDefault(c => c.Id == state.SelectedCharacterId.Value); - if (character is null || !CanActivateCharacter(character, state.User) || state.ActiveCharacterId == character.Id) + if (character is null || !CanActivateCharacter(character, state.User) || + state.ActiveCharacterId == character.Id) return; try @@ -345,13 +360,16 @@ public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeed { var visibleRollIds = state.CampaignLog.Select(entry => entry.RollId).ToHashSet(); - foreach (var rollId in state.CampaignLogDetails.Keys.Where(rollId => !visibleRollIds.Contains(rollId)).ToArray()) + foreach (var rollId in state.CampaignLogDetails.Keys.Where(rollId => !visibleRollIds.Contains(rollId)) + .ToArray()) state.CampaignLogDetails.Remove(rollId); - foreach (var rollId in state.CampaignLogDetailsLoading.Where(rollId => !visibleRollIds.Contains(rollId)).ToArray()) + foreach (var rollId in state.CampaignLogDetailsLoading.Where(rollId => !visibleRollIds.Contains(rollId)) + .ToArray()) state.CampaignLogDetailsLoading.Remove(rollId); - foreach (var rollId in state.CampaignLogDetailErrors.Keys.Where(rollId => !visibleRollIds.Contains(rollId)).ToArray()) + foreach (var rollId in state.CampaignLogDetailErrors.Keys.Where(rollId => !visibleRollIds.Contains(rollId)) + .ToArray()) state.CampaignLogDetailErrors.Remove(rollId); if (state.ExpandedCampaignLogRollId.HasValue && !visibleRollIds.Contains(state.ExpandedCampaignLogRollId.Value)) diff --git a/RpgRoller/Components/Pages/WorkspaceRoute.cs b/RpgRoller/Components/Pages/WorkspaceRoute.cs new file mode 100644 index 0000000..7a3e9aa --- /dev/null +++ b/RpgRoller/Components/Pages/WorkspaceRoute.cs @@ -0,0 +1,8 @@ +namespace RpgRoller.Components.Pages; + +public enum WorkspaceRoute +{ + Play, + Campaigns, + Admin +} diff --git a/RpgRoller/Components/Pages/WorkspaceSessionCoordinator.cs b/RpgRoller/Components/Pages/WorkspaceSessionCoordinator.cs index 9a1a71a..3581f20 100644 --- a/RpgRoller/Components/Pages/WorkspaceSessionCoordinator.cs +++ b/RpgRoller/Components/Pages/WorkspaceSessionCoordinator.cs @@ -3,18 +3,31 @@ using RpgRoller.Contracts; namespace RpgRoller.Components.Pages; -public sealed class WorkspaceSessionCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, IJSRuntime js, RpgRollerApiClient apiClient, WorkspaceQueryService workspaceQuery, Func reloadCampaignsAsync, Func reloadCharacterCampaignOptionsAsync, Func refreshCampaignScopeAsync, Func syncStateEventsAsync, Func stopStateEventsAsync, Func ensureAdminUsersLoadedAsync, Action resetCampaignLogDetailState, Func requestRefreshAsync, Func onLoggedOutAsync) +public sealed class WorkspaceSessionCoordinator( + WorkspaceState state, + WorkspaceFeedbackService feedback, + IJSRuntime js, + RpgRollerApiClient apiClient, + WorkspaceQueryService workspaceQuery, + Func isAdminRoute, + Func redirectToPlayAsync, + Func reloadCampaignsAsync, + Func reloadCharacterCampaignOptionsAsync, + Func refreshCampaignScopeAsync, + Func syncStateEventsAsync, + Func stopStateEventsAsync, + Func ensureAdminUsersLoadedAsync, + Action resetCampaignLogDetailState, + Func onLoggedOutAsync) { public async Task InitializeAsync() { - var storedScreen = await js.InvokeAsync("rpgRollerApi.getSessionValue", ScreenSessionKey); - state.CurrentScreen = NormalizeRequestedScreen(storedScreen) ?? ScreenPlay; - var storedPanel = await js.InvokeAsync("rpgRollerApi.getSessionValue", MobilePanelSessionKey); if (string.Equals(storedPanel, "log", StringComparison.OrdinalIgnoreCase)) state.MobilePanel = "log"; - var storedRollVisibility = await js.InvokeAsync("rpgRollerApi.getSessionValue", RollVisibilitySessionKey); + var storedRollVisibility = + await js.InvokeAsync("rpgRollerApi.getSessionValue", RollVisibilitySessionKey); state.RollVisibility = NormalizeRollVisibility(storedRollVisibility); Guid? preferredCampaignId = null; @@ -78,30 +91,6 @@ public sealed class WorkspaceSessionCoordinator(WorkspaceState state, WorkspaceF await onLoggedOutAsync("Logged out."); } - public async Task SwitchScreenAsync(string screen) - { - var targetScreen = NormalizeRequestedScreen(screen) ?? ScreenPlay; - if (string.Equals(targetScreen, ScreenAdmin, StringComparison.OrdinalIgnoreCase) && !state.IsCurrentUserAdmin) - targetScreen = ScreenPlay; - - state.CurrentScreen = targetScreen; - state.IsScreenMenuOpen = false; - await PersistScreenPreferenceAsync(state.CurrentScreen); - await requestRefreshAsync(); - - if (state.User is not null) - { - await refreshCampaignScopeAsync(); - await syncStateEventsAsync(); - } - - if (state.IsAdminScreen) - { - await ensureAdminUsersLoadedAsync(); - await requestRefreshAsync(); - } - } - public async Task OnRollVisibilityChangedAsync(string visibility) { state.RollVisibility = NormalizeRollVisibility(visibility); @@ -168,14 +157,15 @@ public sealed class WorkspaceSessionCoordinator(WorkspaceState state, WorkspaceF state.User = me.User; state.ActiveCharacterId = me.ActiveCharacterId; - await EnsureScreenAccessAsync(); + if (!await EnsureRouteAccessAsync()) + return true; await reloadCampaignsAsync(preferredCampaignId ?? me.CurrentCampaignId); await reloadCharacterCampaignOptionsAsync(); await refreshCampaignScopeAsync(); await syncStateEventsAsync(); - if (state.IsAdminScreen) + if (isAdminRoute()) await ensureAdminUsersLoadedAsync(); return true; @@ -193,33 +183,17 @@ public sealed class WorkspaceSessionCoordinator(WorkspaceState state, WorkspaceF } } - private async Task EnsureScreenAccessAsync() + private async Task EnsureRouteAccessAsync() { - if (state.IsCurrentUserAdmin) - return; + if (state.IsCurrentUserAdmin || !isAdminRoute()) + { + return true; + } state.AdminUsers = []; state.HasLoadedAdminUsers = false; - - if (!state.IsAdminScreen) - return; - - state.CurrentScreen = ScreenPlay; - await PersistScreenPreferenceAsync(state.CurrentScreen); - } - - private async Task PersistScreenPreferenceAsync(string screen) - { - try - { - await js.InvokeVoidAsync("rpgRollerApi.setSessionValue", ScreenSessionKey, screen); - } - catch (JSDisconnectedException) - { - } - catch (InvalidOperationException ex) when (IsStaticRenderInteropException(ex)) - { - } + await redirectToPlayAsync(); + return false; } private static string NormalizeRollVisibility(string? visibility) @@ -227,29 +201,6 @@ public sealed class WorkspaceSessionCoordinator(WorkspaceState state, WorkspaceF return string.Equals(visibility, "private", StringComparison.OrdinalIgnoreCase) ? "private" : "public"; } - private static string? NormalizeRequestedScreen(string? screen) - { - if (string.Equals(screen, ScreenAdmin, StringComparison.OrdinalIgnoreCase)) - return ScreenAdmin; - - if (string.Equals(screen, ScreenManagement, StringComparison.OrdinalIgnoreCase)) - return ScreenManagement; - - if (string.Equals(screen, ScreenPlay, StringComparison.OrdinalIgnoreCase)) - return ScreenPlay; - - return null; - } - - private static bool IsStaticRenderInteropException(InvalidOperationException exception) - { - return exception.Message.Contains("JavaScript interop calls cannot be issued", StringComparison.OrdinalIgnoreCase); - } - - private const string ScreenPlay = "play"; - private const string ScreenManagement = "management"; - private const string ScreenAdmin = "admin"; - private const string ScreenSessionKey = "screen"; private const string CampaignSessionKey = "campaign"; private const string MobilePanelSessionKey = "play-panel"; private const string RollVisibilitySessionKey = "roll-visibility"; diff --git a/RpgRoller/Components/Pages/WorkspaceState.cs b/RpgRoller/Components/Pages/WorkspaceState.cs index 44ea48f..50d9db4 100644 --- a/RpgRoller/Components/Pages/WorkspaceState.cs +++ b/RpgRoller/Components/Pages/WorkspaceState.cs @@ -17,7 +17,9 @@ 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; } @@ -26,8 +28,10 @@ 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; } @@ -59,7 +63,6 @@ public sealed class WorkspaceState public bool HasHealthIssue { get; set; } public string HealthIssueMessage { get; set; } = "Retry to restore the API connection."; public List Toasts { get; } = []; - public string CurrentScreen { get; set; } = "play"; public string MobilePanel { get; set; } = "character"; public string ConnectionState { get; set; } = "offline"; public string LiveAnnouncement { get; set; } = string.Empty; @@ -88,7 +91,9 @@ public sealed class WorkspaceState public HashSet CampaignLogDetailsLoading { get; } = []; public Dictionary CampaignLogDetailErrors { get; } = []; - public string? SelectedCampaignName => SelectedCampaign?.Name ?? Campaigns.FirstOrDefault(campaign => campaign.Id == SelectedCampaignId)?.Name; + public string? SelectedCampaignName => SelectedCampaign?.Name ?? + Campaigns.FirstOrDefault(campaign => campaign.Id == SelectedCampaignId) + ?.Name; public CharacterSummary? SelectedCharacter => SelectedCampaign?.Characters.FirstOrDefault(character => character.Id == SelectedCharacterId); @@ -101,11 +106,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, + []); - 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); } } @@ -119,14 +127,18 @@ 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; } @@ -157,23 +169,17 @@ public sealed class WorkspaceState public bool IsSelectedCampaignD6 => string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase); - public bool IsPlayScreen => string.Equals(CurrentScreen, "play", StringComparison.OrdinalIgnoreCase); - public bool IsManagementScreen => string.Equals(CurrentScreen, "management", StringComparison.OrdinalIgnoreCase); - public bool IsAdminScreen => string.Equals(CurrentScreen, "admin", StringComparison.OrdinalIgnoreCase); - 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 AppCssClass => IsPlayScreen ? "rr-app app-play" : "rr-app"; } \ No newline at end of file diff --git a/TASKS.md b/TASKS.md index 54c6930..94a12e1 100644 --- a/TASKS.md +++ b/TASKS.md @@ -18,11 +18,11 @@ The change is complete when a human can run the app, open `/`, observe the corre - [x] (2026-05-04 17:52Z) Updated `README.md` so it accurately describes the current architecture and the approved rewrite direction. - [x] (2026-05-04 18:29Z) Implemented a host-level `/` redirect to `/login` or `/play`, moved the static auth document to `/login`, switched login/logout targets to `/play` and `/login`, and updated the root-path host and smoke coverage to the new contract. - [x] (2026-05-04 19:26Z) Replaced the checked-in Playwright smoke coverage with a geckodriver+Selenium smoke runner, including a Firefox DOM-wrap addon for extension-like startup mutations, and updated repo scripts/docs to the new browser verification path. -- [ ] Introduce real authenticated routes for `/play`, `/campaigns`, and `/admin` while preserving current behavior. -- [ ] Remove `screen` as a `sessionStorage` routing mechanism and replace menu actions with URL navigation. +- [x] (2026-05-04) Introduced real authenticated routes for `/play`, `/campaigns`, and `/admin` while preserving the shared `Workspace` behavior behind those routes. +- [x] (2026-05-04) Removed `screen` as a `sessionStorage` routing mechanism and replaced menu actions with URL navigation. - [ ] Split the large `Workspace` render tree so play, campaign management, and admin each own a smaller subtree. - [ ] Reduce `OnAfterRenderAsync` to the smallest practical scope and keep staged startup out of the authenticated shell root. -- [ ] Update host tests, Selenium smoke tests, and docs so the new route model is the only documented and verified behavior. +- [x] (2026-05-04) Updated host tests, Selenium smoke tests, and docs so the real-route model is the documented and verified Milestone 2 behavior. ## Surprises & Discoveries @@ -78,15 +78,17 @@ After Milestone 1, the dual-purpose `/` entry point is gone. Anonymous requests After the Selenium migration iteration, the repository’s browser smoke coverage once again matches the documented verification path. The smoke suite now runs against Firefox through geckodriver, and the DOM-wrap regression coverage remains intact through a temporary test addon. The next risk is purely architectural again: the authenticated shell still uses in-memory screen switching, so Milestone 2 remains the next code change on the critical path. +After Milestone 2, the authenticated shell now has first-class `/play`, `/campaigns`, and `/admin` routes, and the menu navigates with URLs instead of `sessionStorage` screen names. The remaining risk is now narrower and more structural: `Workspace.razor` still owns mutually exclusive authenticated branches, and the root `OnAfterRenderAsync` path still stages page-specific startup work that should move into route-owned components in Milestones 3 and 4. + This section must be updated after each major milestone. When the implementation is complete, summarize which parts of the old workspace architecture were fully removed, which compatibility constraints remain, and whether the final startup path still depends on any multi-batch structural rendering. ## Context and Orientation -The current app serves both anonymous and authenticated experiences from the same root request path. In `RpgRoller/Components/App.razor`, the HTML shell checks the current request path and session cookie through `HttpContext`. If the request is for `/` and no valid session exists, it renders `RpgRoller/Components/Pages/HomeControls/StaticAuthPage.razor` as plain HTML and loads `RpgRoller/wwwroot/js/rpgroller-api.js`. That JavaScript file binds the login and registration forms and sends `fetch` requests to `/api/auth/register` and `/api/auth/login`. +The current app serves both anonymous and authenticated experiences from the same HTML shell. In `RpgRoller/Components/App.razor`, the shell checks the current request path through `HttpContext`. If the request is for `/login`, it renders `RpgRoller/Components/Pages/HomeControls/StaticAuthPage.razor` as plain HTML and loads `RpgRoller/wwwroot/js/rpgroller-api.js`. That JavaScript file binds the login and registration forms and sends `fetch` requests to `/api/auth/register` and `/api/auth/login`. -If a valid session cookie exists, `App.razor` instead renders the interactive Blazor router. The only current component route for the authenticated shell is `RpgRoller/Components/Pages/Home.razor`, which maps `@page "/"` and immediately renders `Workspace`. `RpgRoller/Components/Pages/Home.razor.cs` is only a logout redirect helper; it is not a real page controller anymore. +If a valid session cookie exists, `App.razor` instead renders the interactive Blazor router. The authenticated shell is now entered through `RpgRoller/Components/Pages/PlayPage.razor`, `CampaignsPage.razor`, and `AdminPage.razor`, which map `/play`, `/campaigns`, and `/admin` and forward into the shared `Workspace` component. -The authenticated workspace lives in `RpgRoller/Components/Pages/Workspace.razor` and `Workspace.razor.cs`. The Razor file contains the header, play screen, campaign management screen, admin screen, toasts, and modals. The code-behind wires several coordinator classes, and `OnAfterRenderAsync` drives session initialization and staged control enablement. The currently selected screen is stored in `WorkspaceState.CurrentScreen`, and `WorkspaceSessionCoordinator.cs` persists that screen name in browser `sessionStorage` under the key `rpgroller.screen`. +The authenticated workspace lives in `RpgRoller/Components/Pages/Workspace.razor` and `Workspace.razor.cs`. The Razor file still contains the header, play screen, campaign management screen, admin screen, toasts, and modals. The code-behind wires several coordinator classes, and `OnAfterRenderAsync` still drives session initialization and staged control enablement. The current route now comes from the page component parameter rather than `WorkspaceState.CurrentScreen`, and `WorkspaceSessionCoordinator.cs` no longer persists a screen name in browser `sessionStorage`. In plain language, a “route-first authenticated shell” means that the browser path decides which authenticated page is being rendered. `/play` means the play page. `/campaigns` means the campaign management page. `/admin` means the admin page. The URL is not a decorative detail; it is the primary way the app chooses the screen. Menu clicks change the URL. Reloading the page preserves the same screen because the URL already says what the screen is. @@ -249,12 +251,12 @@ Current evidence that explains the bootstrap constraint: RpgRoller/Components/RpgRollerApiClient.cs var response = await js.InvokeAsync("rpgRollerApi.request", method, path, payload); -Current evidence that explains why route navigation must replace screen persistence: +Current evidence that Milestone 2 is intentionally transitional rather than final: - RpgRoller/Components/Pages/WorkspaceSessionCoordinator.cs - private const string ScreenSessionKey = "screen"; - state.CurrentScreen = NormalizeRequestedScreen(storedScreen) ?? ScreenPlay; - await js.InvokeVoidAsync("rpgRollerApi.setSessionValue", ScreenSessionKey, state.CurrentScreen); + RpgRoller/Components/Pages/Workspace.razor + @if (IsPlayRoute) { ... } + else if (IsCampaignsRoute) { ... } + else if (IsAdminRoute) { ... } ## Interfaces and Dependencies diff --git a/tests/e2e/lib/selenium-smoke.js b/tests/e2e/lib/selenium-smoke.js index 850fce2..d64e23f 100644 --- a/tests/e2e/lib/selenium-smoke.js +++ b/tests/e2e/lib/selenium-smoke.js @@ -1,7 +1,7 @@ const assert = require("node:assert/strict"); const fs = require("node:fs"); const path = require("node:path"); -const { Builder, By, until } = require("selenium-webdriver"); +const { Builder, By, Key, until } = require("selenium-webdriver"); const firefox = require("selenium-webdriver/firefox"); const baseUrl = process.env.SELENIUM_BASE_URL || "http://127.0.0.1:5000"; diff --git a/tests/e2e/smoke.js b/tests/e2e/smoke.js index eb826e6..fc0f529 100644 --- a/tests/e2e/smoke.js +++ b/tests/e2e/smoke.js @@ -87,6 +87,61 @@ const tests = [ assert.equal(response.headers.get("location"), "/play"); } }, + { + name: "authenticated route navigation and refresh use real URLs", + run: async () => withDriver({}, async (driver) => { + const username = uniqueName("routes"); + const { sessionCookie } = await registerAndLoginApi(username, "Route Navigation"); + + const campaign = await postJson("/api/campaigns", { + name: "Route Navigation", + rulesetId: "d6" + }, { cookie: sessionCookie }); + + await postJson("/api/characters", { + name: "Navigator", + campaignId: campaign.id + }, { cookie: sessionCookie }); + + await seedAuthenticatedBrowser(driver, sessionCookie); + await driver.get(absoluteUrl("/campaigns")); + await waitForUrl(driver, "/campaigns"); + await waitForSelector(driver, "#campaign-select"); + assert.equal(await hasSelector(driver, "#skill-filter-input"), false); + + await driver.navigate().refresh(); + await waitForUrl(driver, "/campaigns"); + await waitForSelector(driver, "#campaign-select"); + + await clickSelector(driver, ".menu-toggle"); + await clickText(driver, ".menu-item", "Play"); + await waitForUrl(driver, "/play"); + await waitForSelector(driver, "#skill-filter-input"); + + await driver.navigate().refresh(); + await waitForUrl(driver, "/play"); + await waitForSelector(driver, "#skill-filter-input"); + + await clickSelector(driver, ".menu-toggle"); + await clickText(driver, ".menu-item", "Campaign Management"); + await waitForUrl(driver, "/campaigns"); + await waitForSelector(driver, "#campaign-select"); + }) + }, + { + name: "non-admin users are redirected away from admin route", + run: async () => withDriver({}, async (driver) => { + const username = uniqueName("admin-redirect"); + const { sessionCookie } = await registerAndLoginApi(username, "Admin Redirect"); + + await seedAuthenticatedBrowser(driver, sessionCookie); + await driver.get(absoluteUrl("/admin")); + + await waitForUrl(driver, "/play"); + await waitForText(driver, "Campaign Log"); + assert.equal(await hasSelector(driver, ".management-list"), false); + }) + }, { name: "successful login transitions to play workspace", run: async () => withDriver({}, async (driver) => {