diff --git a/RpgRoller/Components/Pages/AdminPage.razor b/RpgRoller/Components/Pages/AdminPage.razor index bb32e53..92f0063 100644 --- a/RpgRoller/Components/Pages/AdminPage.razor +++ b/RpgRoller/Components/Pages/AdminPage.razor @@ -1,3 +1,7 @@ @page "/admin" @inherits AuthenticatedPageBase - + + + + + diff --git a/RpgRoller/Components/Pages/AdminWorkspaceContent.razor b/RpgRoller/Components/Pages/AdminWorkspaceContent.razor new file mode 100644 index 0000000..e494198 --- /dev/null +++ b/RpgRoller/Components/Pages/AdminWorkspaceContent.razor @@ -0,0 +1,68 @@ +@using Microsoft.AspNetCore.Components + +
+ @if (Workspace.State.IsCurrentUserAdmin) + { +
+
+

Database

+
+

Download the current SQLite file for backup or offline inspection.

+ +
+ } +
+
+

User Management

+
+ @if (Workspace.State.IsAdminDataLoading) + { +

Loading users...

+ } + else if (!Workspace.State.IsCurrentUserAdmin) + { +

Admin role is required to manage users.

+ } + else if (Workspace.State.AdminUsers.Count == 0) + { +

No users found.

+ } + else + { +
    + @foreach (var user in Workspace.State.AdminUsers) + { +
  • +
    + @user.Username +

    @user.DisplayName

    +

    Roles: @(user.Roles.Count == 0 ? "none" : string.Join(", ", user.Roles))

    +
    +
    + + +
    +
  • + } +
+ } +
+
+ +@code { + [Parameter, EditorRequired] public WorkspacePageContext Workspace { get; set; } = null!; +} diff --git a/RpgRoller/Components/Pages/CampaignsPage.razor b/RpgRoller/Components/Pages/CampaignsPage.razor index f55bdfe..27a2bea 100644 --- a/RpgRoller/Components/Pages/CampaignsPage.razor +++ b/RpgRoller/Components/Pages/CampaignsPage.razor @@ -1,3 +1,7 @@ @page "/campaigns" @inherits AuthenticatedPageBase - + + + + + diff --git a/RpgRoller/Components/Pages/CampaignsWorkspaceContent.razor b/RpgRoller/Components/Pages/CampaignsWorkspaceContent.razor new file mode 100644 index 0000000..675cb59 --- /dev/null +++ b/RpgRoller/Components/Pages/CampaignsWorkspaceContent.razor @@ -0,0 +1,31 @@ +@using Microsoft.AspNetCore.Components +@using RpgRoller.Components.Pages.HomeControls + + + + + +@code { + [Parameter, EditorRequired] public WorkspacePageContext Workspace { get; set; } = null!; + + private async Task OnCampaignSelectionChangedAsync(ChangeEventArgs args) + { + await Workspace.Campaigns.OnCampaignSelectionChangedAsync(args); + await Workspace.RequestRefreshAsync(); + } +} diff --git a/RpgRoller/Components/Pages/CharacterManagementModals.razor b/RpgRoller/Components/Pages/CharacterManagementModals.razor new file mode 100644 index 0000000..b7a5297 --- /dev/null +++ b/RpgRoller/Components/Pages/CharacterManagementModals.razor @@ -0,0 +1,40 @@ +@using Microsoft.AspNetCore.Components +@using RpgRoller.Components.Pages.HomeControls + + + + + +@code { + [Parameter, EditorRequired] public WorkspacePageContext Workspace { get; set; } = null!; +} diff --git a/RpgRoller/Components/Pages/PlayPage.razor b/RpgRoller/Components/Pages/PlayPage.razor index de99433..7ad3c1d 100644 --- a/RpgRoller/Components/Pages/PlayPage.razor +++ b/RpgRoller/Components/Pages/PlayPage.razor @@ -1,3 +1,7 @@ @page "/play" @inherits AuthenticatedPageBase - + + + + + diff --git a/RpgRoller/Components/Pages/PlayWorkspaceContent.razor b/RpgRoller/Components/Pages/PlayWorkspaceContent.razor new file mode 100644 index 0000000..a2735bb --- /dev/null +++ b/RpgRoller/Components/Pages/PlayWorkspaceContent.razor @@ -0,0 +1,77 @@ +@using Microsoft.AspNetCore.Components +@using RpgRoller.Components.Pages.HomeControls + +
+ + + +
+ + + + + + +@code { + [Parameter, EditorRequired] public WorkspacePageContext Workspace { get; set; } = null!; +} diff --git a/RpgRoller/Components/Pages/Workspace.razor b/RpgRoller/Components/Pages/Workspace.razor index d786f61..2fcbe9b 100644 --- a/RpgRoller/Components/Pages/Workspace.razor +++ b/RpgRoller/Components/Pages/Workspace.razor @@ -27,149 +27,9 @@ MenuItems="HeaderMenuItems" ToggleMenuRequested="ToggleScreenMenu" LogoutRequested="Session.LogoutAsync"/> - - @if (IsPlayRoute) + @if (ChildContent is not null) { -
- - - -
- - } - else if (IsCampaignsRoute) - { - - } - else if (IsAdminRoute) - { -
- @if (State.IsCurrentUserAdmin) - { -
-
-

Database

-
-

Download the current SQLite file for backup or offline inspection.

- -
- } -
-
-

User Management

-
- @if (State.IsAdminDataLoading) - { -

Loading users...

- } - else if (!State.IsCurrentUserAdmin) - { -

Admin role is required to manage users.

- } - else if (State.AdminUsers.Count == 0) - { -

No users found.

- } - else - { -
    - @foreach (var user in State.AdminUsers) - { -
  • -
    - @user.Username -

    @user.DisplayName

    -

    Roles: @(user.Roles.Count == 0 ? "none" : string.Join(", ", user.Roles))

    -
    -
    - - -
    -
  • - } -
- } -
-
+ @ChildContent(PageContext) } @@ -185,49 +45,3 @@ } - - - - - - diff --git a/RpgRoller/Components/Pages/Workspace.razor.cs b/RpgRoller/Components/Pages/Workspace.razor.cs index b23f718..1ebf58f 100644 --- a/RpgRoller/Components/Pages/Workspace.razor.cs +++ b/RpgRoller/Components/Pages/Workspace.razor.cs @@ -121,6 +121,11 @@ public partial class Workspace : IAsyncDisposable return Task.CompletedTask; } + private Task RequestRefreshAsync() + { + return InvokeAsync(StateHasChanged); + } + private static bool IsStaticRenderInteropException(InvalidOperationException exception) { return exception.Message.Contains("statically rendered", StringComparison.OrdinalIgnoreCase); @@ -136,6 +141,7 @@ public partial class Workspace : IAsyncDisposable [Parameter] public EventCallback LoggedOut { get; set; } [Parameter] public WorkspaceRoute Route { get; set; } = WorkspaceRoute.Play; + [Parameter] public RenderFragment? ChildContent { get; set; } private WorkspaceState State { get; } = new(); private bool HasSessionInitialized { get; set; } @@ -146,6 +152,10 @@ public partial class Workspace : IAsyncDisposable private bool IsAdminRoute => Route == WorkspaceRoute.Admin; private string AppCssClass => IsPlayRoute ? "rr-app app-play" : "rr-app"; + private WorkspacePageContext PageContext => new(State, Play, Campaigns, Admin, Scope, Session, + RequestRefreshAsync, EnableCharacterControls, EnableCustomRollComposer, AdminDatabaseDownloadUrl, + HeaderMenuItems, IsPlayRoute, IsCampaignsRoute, IsAdminRoute); + private WorkspaceCampaignScopeCoordinator Scope => m_Scope ??= new(State, Feedback, JS, WorkspaceQuery, () => IsPlayRoute, Play.EnsureSelectedCharacterActiveAsync, Play.RefreshSelectedCharacterSheetAsync, Play.RefreshCampaignLogAsync, Play.ResetCampaignLogDetailState, Play.ResetCampaignStateTracking, diff --git a/RpgRoller/Components/Pages/WorkspacePageContext.cs b/RpgRoller/Components/Pages/WorkspacePageContext.cs new file mode 100644 index 0000000..3ddd2c8 --- /dev/null +++ b/RpgRoller/Components/Pages/WorkspacePageContext.cs @@ -0,0 +1,35 @@ +using RpgRoller.Components.Pages.HomeControls; + +namespace RpgRoller.Components.Pages; + +public sealed class WorkspacePageContext( + WorkspaceState state, + WorkspacePlayCoordinator play, + WorkspaceCampaignCoordinator campaigns, + WorkspaceAdminCoordinator admin, + WorkspaceCampaignScopeCoordinator scope, + WorkspaceSessionCoordinator session, + Func requestRefreshAsync, + bool enableCharacterControls, + bool enableCustomRollComposer, + string adminDatabaseDownloadUrl, + IReadOnlyList headerMenuItems, + bool isPlayRoute, + bool isCampaignsRoute, + bool isAdminRoute) +{ + public WorkspaceState State { get; } = state; + public WorkspacePlayCoordinator Play { get; } = play; + public WorkspaceCampaignCoordinator Campaigns { get; } = campaigns; + public WorkspaceAdminCoordinator Admin { get; } = admin; + public WorkspaceCampaignScopeCoordinator Scope { get; } = scope; + public WorkspaceSessionCoordinator Session { get; } = session; + public Func RequestRefreshAsync { get; } = requestRefreshAsync; + public bool EnableCharacterControls { get; } = enableCharacterControls; + public bool EnableCustomRollComposer { get; } = enableCustomRollComposer; + public string AdminDatabaseDownloadUrl { get; } = adminDatabaseDownloadUrl; + public IReadOnlyList HeaderMenuItems { get; } = headerMenuItems; + public bool IsPlayRoute { get; } = isPlayRoute; + public bool IsCampaignsRoute { get; } = isCampaignsRoute; + public bool IsAdminRoute { get; } = isAdminRoute; +} \ No newline at end of file diff --git a/TASKS.md b/TASKS.md index 94a12e1..c947bc0 100644 --- a/TASKS.md +++ b/TASKS.md @@ -20,7 +20,7 @@ The change is complete when a human can run the app, open `/`, observe the corre - [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. - [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. +- [x] (2026-05-04 21:42Z) Split the large `Workspace` render tree into a shared shell plus route-owned play, campaign-management, and admin content components, and kept the Selenium route and DOM-wrap coverage green after the split. - [ ] Reduce `OnAfterRenderAsync` to the smallest practical scope and keep staged startup out of the authenticated shell root. - [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. @@ -80,6 +80,8 @@ After the Selenium migration iteration, the repository’s browser smoke coverag 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. +After Milestone 3, `Workspace.razor` is now a shell that owns shared chrome, health state, and toast feedback, while the play, campaign-management, and admin DOM each live in route-owned components supplied by `/play`, `/campaigns`, and `/admin`. The route split preserved the host tests and full Selenium smoke coverage, including the DOM-wrap regression case, but the final startup path is still staged through `Workspace.razor.cs` and remains the next target for Milestone 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