refactor: split workspace routes

This commit is contained in:
2026-05-04 21:45:44 +02:00
parent def2a3f680
commit 9c3f7c039e
11 changed files with 281 additions and 192 deletions

View File

@@ -1,3 +1,7 @@
@page "/admin" @page "/admin"
@inherits AuthenticatedPageBase @inherits AuthenticatedPageBase
<Workspace Route="WorkspaceRoute.Admin" LoggedOut="OnLoggedOutAsync"/> <Workspace Route="WorkspaceRoute.Admin" LoggedOut="OnLoggedOutAsync">
<ChildContent Context="workspace">
<AdminWorkspaceContent Workspace="workspace"/>
</ChildContent>
</Workspace>

View File

@@ -0,0 +1,68 @@
@using Microsoft.AspNetCore.Components
<main class="management-screen">
@if (Workspace.State.IsCurrentUserAdmin)
{
<section class="card">
<div class="section-head">
<h2>Database</h2>
</div>
<p class="muted">Download the current SQLite file for backup or offline inspection.</p>
<div class="management-actions">
<a class="action-link" href="@Workspace.AdminDatabaseDownloadUrl" download>Download SQLite database</a>
</div>
</section>
}
<section class="card">
<div class="section-head">
<h2>User Management</h2>
</div>
@if (Workspace.State.IsAdminDataLoading)
{
<p class="empty">Loading users...</p>
}
else if (!Workspace.State.IsCurrentUserAdmin)
{
<p class="empty">Admin role is required to manage users.</p>
}
else if (Workspace.State.AdminUsers.Count == 0)
{
<p class="empty">No users found.</p>
}
else
{
<ul class="management-list">
@foreach (var user in Workspace.State.AdminUsers)
{
<li>
<div>
<strong>@user.Username</strong>
<p class="muted">@user.DisplayName</p>
<p class="muted">Roles: @(user.Roles.Count == 0 ? "none" : string.Join(", ", user.Roles))</p>
</div>
<div class="skill-chip-actions">
<button type="button"
class="chip-button"
disabled="@(Workspace.State.IsMutating || user.Id == Workspace.State.User?.Id)"
@onclick="() => Workspace.Admin.ToggleAdminRoleAsync(user)">
<span aria-hidden="true" class="emoji">🛡️</span>
<span class="sr-only">Toggle admin role for @user.Username</span>
</button>
<button type="button"
class="chip-button"
disabled="@(Workspace.State.IsMutating || user.Id == Workspace.State.User?.Id)"
@onclick="() => Workspace.Admin.DeleteUserAsync(user)">
<span aria-hidden="true" class="emoji">🗑️</span>
<span class="sr-only">Delete user @user.Username</span>
</button>
</div>
</li>
}
</ul>
}
</section>
</main>
@code {
[Parameter, EditorRequired] public WorkspacePageContext Workspace { get; set; } = null!;
}

View File

@@ -1,3 +1,7 @@
@page "/campaigns" @page "/campaigns"
@inherits AuthenticatedPageBase @inherits AuthenticatedPageBase
<Workspace Route="WorkspaceRoute.Campaigns" LoggedOut="OnLoggedOutAsync"/> <Workspace Route="WorkspaceRoute.Campaigns" LoggedOut="OnLoggedOutAsync">
<ChildContent Context="workspace">
<CampaignsWorkspaceContent Workspace="workspace"/>
</ChildContent>
</Workspace>

View File

@@ -0,0 +1,31 @@
@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"
OwnerLabel="Workspace.State.OwnerLabel"
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"
EditCharacterRequested="Workspace.Campaigns.OpenEditCharacterModal"
DeleteCharacterRequested="Workspace.Campaigns.DeleteCharacterAsync"/>
<CharacterManagementModals Workspace="Workspace"/>
@code {
[Parameter, EditorRequired] public WorkspacePageContext Workspace { get; set; } = null!;
private async Task OnCampaignSelectionChangedAsync(ChangeEventArgs args)
{
await Workspace.Campaigns.OnCampaignSelectionChangedAsync(args);
await Workspace.RequestRefreshAsync();
}
}

View File

@@ -0,0 +1,40 @@
@using Microsoft.AspNetCore.Components
@using RpgRoller.Components.Pages.HomeControls
<CharacterFormModal
Visible="Workspace.State.ShowCreateCharacterModal"
Title="Create Character"
SubmitLabel="Create Character"
NameInputId="character-create-name"
CampaignInputId="character-create-campaign"
OwnerUsernameInputId="character-create-owner"
InitialModel="Workspace.State.CreateCharacterInitialModel"
FormVersion="Workspace.State.CreateCharacterFormVersion"
EditingCharacterId="null"
CampaignOptions="Workspace.State.CharacterCampaignOptions"
IsMutating="Workspace.State.IsMutating"
AllowOwnerEdit="false"
AvailableUsernames="Workspace.State.KnownUsernames"
CharacterSaved="Workspace.Campaigns.OnCharacterCreatedAsync"
CancelRequested="Workspace.Campaigns.CloseCharacterModals"/>
<CharacterFormModal
Visible="Workspace.State.ShowEditCharacterModal"
Title="Edit Character"
SubmitLabel="Save Character"
NameInputId="character-edit-name"
CampaignInputId="character-edit-campaign"
OwnerUsernameInputId="character-edit-owner"
InitialModel="Workspace.State.EditCharacterInitialModel"
FormVersion="Workspace.State.EditCharacterFormVersion"
EditingCharacterId="Workspace.State.EditingCharacterId"
CampaignOptions="Workspace.State.CharacterCampaignOptions"
IsMutating="Workspace.State.IsMutating"
AllowOwnerEdit="Workspace.State.CanEditCharacterOwner"
AvailableUsernames="Workspace.State.KnownUsernames"
CharacterSaved="Workspace.Campaigns.OnCharacterUpdatedAsync"
CancelRequested="Workspace.Campaigns.CloseCharacterModals"/>
@code {
[Parameter, EditorRequired] public WorkspacePageContext Workspace { get; set; } = null!;
}

View File

@@ -1,3 +1,7 @@
@page "/play" @page "/play"
@inherits AuthenticatedPageBase @inherits AuthenticatedPageBase
<Workspace Route="WorkspaceRoute.Play" LoggedOut="OnLoggedOutAsync"/> <Workspace Route="WorkspaceRoute.Play" LoggedOut="OnLoggedOutAsync">
<ChildContent Context="workspace">
<PlayWorkspaceContent Workspace="workspace"/>
</ChildContent>
</Workspace>

View File

@@ -0,0 +1,77 @@
@using Microsoft.AspNetCore.Components
@using RpgRoller.Components.Pages.HomeControls
<main class="play-screen @(Workspace.State.MobilePanel == "log" ? "mobile-log" : "mobile-character")">
<CharacterPanel
IsCampaignDataLoading="Workspace.State.IsCampaignDataLoading"
SelectedCampaign="Workspace.State.PlaySelectedCampaign"
SelectedCharacterId="Workspace.State.PlaySelectedCharacterId"
SelectedCharacter="Workspace.State.PlaySelectedCharacter"
IsMutating="Workspace.State.IsMutating"
SelectedCharacterSkills="Workspace.State.PlaySelectedCharacterSkills"
SelectedCharacterSkillGroups="Workspace.State.PlaySelectedCharacterSkillGroups"
SelectedCampaignRulesetId="@(Workspace.State.PlaySelectedCampaign?.RulesetId ?? string.Empty)"
RollVisibility="Workspace.State.RollVisibility"
EnableInteractiveControls="Workspace.EnableCharacterControls"
RollVisibilityChanged="Workspace.Session.OnRollVisibilityChangedAsync"
OwnerLabel="Workspace.State.OwnerLabel"
SkillDefinitionLabel="Workspace.State.SkillDefinitionLabel"
CanEditCharacter="Workspace.Campaigns.CanEditCharacter"
CanEditSkill="Workspace.Play.CanEditSkill"
CharacterSelected="Workspace.Play.SelectCharacterAsync"
EditCharacterRequested="Workspace.Campaigns.OpenEditCharacterModal"
SkillCreated="Workspace.Play.OnSkillCreatedAsync"
SkillUpdated="Workspace.Play.OnSkillUpdatedAsync"
SkillGroupCreated="Workspace.Play.OnSkillGroupCreatedAsync"
SkillGroupUpdated="Workspace.Play.OnSkillGroupUpdatedAsync"
SkillDeleted="Workspace.Play.OnSkillDeletedAsync"
SkillGroupDeleted="Workspace.Play.OnSkillGroupDeletedAsync"
ErrorOccurred="Workspace.Play.OnCharacterPanelErrorAsync"
RollRequested="Workspace.Play.RollSkillAsync"/>
<CampaignLogPanel
IsCampaignDataLoading="Workspace.State.IsCampaignDataLoading"
CampaignLog="Workspace.State.PlayVisibleCampaignLog"
ExpandedRollId="Workspace.State.ExpandedCampaignLogRollId"
FreshRollId="Workspace.State.FreshCampaignLogRollId"
SelectedCharacterId="Workspace.State.PlaySelectedCharacterId"
SelectedCharacterName="@(Workspace.State.PlaySelectedCharacter?.Name)"
SelectedCampaignRulesetId="@(Workspace.State.PlaySelectedCampaign?.RulesetId ?? string.Empty)"
RollVisibility="Workspace.State.RollVisibility"
EnableCustomRollComposer="Workspace.EnableCustomRollComposer"
IsMutating="Workspace.State.IsMutating"
ToggleRollDetailRequested="Workspace.Play.ToggleRollDetailAsync"
ResolveRollDetail="Workspace.Play.ResolveRollDetail"
IsRollDetailLoading="Workspace.Play.IsRollDetailLoading"
GetRollDetailError="Workspace.Play.GetRollDetailError"
CustomRollCreated="Workspace.Play.OnCustomRollCreatedAsync"
ErrorOccurred="Workspace.Play.OnCampaignLogPanelErrorAsync"/>
</main>
<nav class="mobile-bottom-nav" aria-label="Play panel selector">
<button type="button" class="switch @(Workspace.State.MobilePanel == "character" ? "active" : string.Empty)"
@onclick='() => Workspace.Scope.SetMobilePanelAsync("character")'>
Character
</button>
<button type="button" class="switch @(Workspace.State.MobilePanel == "log" ? "active" : string.Empty)"
@onclick='() => Workspace.Scope.SetMobilePanelAsync("log")'>
Log
</button>
</nav>
<CharacterManagementModals Workspace="Workspace"/>
<RolemasterSkillRollModal
Visible="Workspace.State.ShowRolemasterSkillRollModal"
SkillName="@(Workspace.State.PendingRolemasterSkillRoll?.Name ?? string.Empty)"
Expression="@(Workspace.State.PendingRolemasterSkillRoll?.DiceRollDefinition ?? string.Empty)"
ModifierText="@Workspace.State.PendingRolemasterSituationalModifier"
ModifierTextChanged="@(text => Workspace.State.PendingRolemasterSituationalModifier = text)"
ErrorMessage="@Workspace.State.PendingRolemasterSkillRollError"
IsMutating="Workspace.State.IsMutating"
IsSubmitting="Workspace.State.IsSubmittingRolemasterSkillRoll"
ConfirmRequested="Workspace.Play.SubmitRolemasterSkillRollAsync"
CancelRequested="Workspace.Play.CancelRolemasterSkillRollAsync"/>
@code {
[Parameter, EditorRequired] public WorkspacePageContext Workspace { get; set; } = null!;
}

View File

@@ -27,149 +27,9 @@
MenuItems="HeaderMenuItems" MenuItems="HeaderMenuItems"
ToggleMenuRequested="ToggleScreenMenu" ToggleMenuRequested="ToggleScreenMenu"
LogoutRequested="Session.LogoutAsync"/> LogoutRequested="Session.LogoutAsync"/>
@if (ChildContent is not null)
@if (IsPlayRoute)
{ {
<main class="play-screen @(State.MobilePanel == "log" ? "mobile-log" : "mobile-character")"> @ChildContent(PageContext)
<CharacterPanel
IsCampaignDataLoading="State.IsCampaignDataLoading"
SelectedCampaign="State.PlaySelectedCampaign"
SelectedCharacterId="State.PlaySelectedCharacterId"
SelectedCharacter="State.PlaySelectedCharacter"
IsMutating="State.IsMutating"
SelectedCharacterSkills="State.PlaySelectedCharacterSkills"
SelectedCharacterSkillGroups="State.PlaySelectedCharacterSkillGroups"
SelectedCampaignRulesetId="@(State.PlaySelectedCampaign?.RulesetId ?? string.Empty)"
RollVisibility="State.RollVisibility"
EnableInteractiveControls="EnableCharacterControls"
RollVisibilityChanged="Session.OnRollVisibilityChangedAsync"
OwnerLabel="State.OwnerLabel"
SkillDefinitionLabel="State.SkillDefinitionLabel"
CanEditCharacter="Campaigns.CanEditCharacter"
CanEditSkill="Play.CanEditSkill"
CharacterSelected="Play.SelectCharacterAsync"
EditCharacterRequested="Campaigns.OpenEditCharacterModal"
SkillCreated="Play.OnSkillCreatedAsync"
SkillUpdated="Play.OnSkillUpdatedAsync"
SkillGroupCreated="Play.OnSkillGroupCreatedAsync"
SkillGroupUpdated="Play.OnSkillGroupUpdatedAsync"
SkillDeleted="Play.OnSkillDeletedAsync"
SkillGroupDeleted="Play.OnSkillGroupDeletedAsync"
ErrorOccurred="Play.OnCharacterPanelErrorAsync"
RollRequested="Play.RollSkillAsync"/>
<CampaignLogPanel
IsCampaignDataLoading="State.IsCampaignDataLoading"
CampaignLog="State.PlayVisibleCampaignLog"
ExpandedRollId="State.ExpandedCampaignLogRollId"
FreshRollId="State.FreshCampaignLogRollId"
SelectedCharacterId="State.PlaySelectedCharacterId"
SelectedCharacterName="@(State.PlaySelectedCharacter?.Name)"
SelectedCampaignRulesetId="@(State.PlaySelectedCampaign?.RulesetId ?? string.Empty)"
RollVisibility="State.RollVisibility"
EnableCustomRollComposer="EnableCustomRollComposer"
IsMutating="State.IsMutating"
ToggleRollDetailRequested="Play.ToggleRollDetailAsync"
ResolveRollDetail="Play.ResolveRollDetail"
IsRollDetailLoading="Play.IsRollDetailLoading"
GetRollDetailError="Play.GetRollDetailError"
CustomRollCreated="Play.OnCustomRollCreatedAsync"
ErrorOccurred="Play.OnCampaignLogPanelErrorAsync"/>
</main>
<nav class="mobile-bottom-nav" aria-label="Play panel selector">
<button type="button" class="switch @(State.MobilePanel == "character" ? "active" : string.Empty)"
@onclick='() => Scope.SetMobilePanelAsync("character")'>
Character
</button>
<button type="button" class="switch @(State.MobilePanel == "log" ? "active" : string.Empty)"
@onclick='() => Scope.SetMobilePanelAsync("log")'>
Log
</button>
</nav>
}
else if (IsCampaignsRoute)
{
<CampaignManagementPanel
Campaigns="State.Campaigns"
SelectedCampaignId="State.SelectedCampaignId"
SelectedCampaign="State.SelectedCampaign"
Rulesets="State.Rulesets"
IsMutating="State.IsMutating"
OwnerLabel="State.OwnerLabel"
CanEditCharacter="Campaigns.CanEditCharacter"
CanDeleteCharacter="Campaigns.CanDeleteCharacter"
CanDeleteCampaign="State.CanDeleteSelectedCampaign"
CampaignSelectionChanged="Campaigns.OnCampaignSelectionChangedAsync"
CampaignCreated="Campaigns.OnCampaignCreatedAsync"
DeleteCampaignRequested="Campaigns.DeleteSelectedCampaignAsync"
CreateCharacterRequested="Campaigns.OpenCreateCharacterModal"
EditCharacterRequested="Campaigns.OpenEditCharacterModal"
DeleteCharacterRequested="Campaigns.DeleteCharacterAsync"/>
}
else if (IsAdminRoute)
{
<main class="management-screen">
@if (State.IsCurrentUserAdmin)
{
<section class="card">
<div class="section-head">
<h2>Database</h2>
</div>
<p class="muted">Download the current SQLite file for backup or offline inspection.</p>
<div class="management-actions">
<a class="action-link" href="@AdminDatabaseDownloadUrl" download>Download SQLite database</a>
</div>
</section>
}
<section class="card">
<div class="section-head">
<h2>User Management</h2>
</div>
@if (State.IsAdminDataLoading)
{
<p class="empty">Loading users...</p>
}
else if (!State.IsCurrentUserAdmin)
{
<p class="empty">Admin role is required to manage users.</p>
}
else if (State.AdminUsers.Count == 0)
{
<p class="empty">No users found.</p>
}
else
{
<ul class="management-list">
@foreach (var user in State.AdminUsers)
{
<li>
<div>
<strong>@user.Username</strong>
<p class="muted">@user.DisplayName</p>
<p class="muted">Roles: @(user.Roles.Count == 0 ? "none" : string.Join(", ", user.Roles))</p>
</div>
<div class="skill-chip-actions">
<button type="button"
class="chip-button"
disabled="@(State.IsMutating || user.Id == State.User?.Id)"
@onclick="() => Admin.ToggleAdminRoleAsync(user)">
<span aria-hidden="true" class="emoji">🛡️</span>
<span class="sr-only">Toggle admin role for @user.Username</span>
</button>
<button type="button"
class="chip-button"
disabled="@(State.IsMutating || user.Id == State.User?.Id)"
@onclick="() => Admin.DeleteUserAsync(user)">
<span aria-hidden="true" class="emoji">🗑️</span>
<span class="sr-only">Delete user @user.Username</span>
</button>
</div>
</li>
}
</ul>
}
</section>
</main>
} }
</div> </div>
@@ -185,49 +45,3 @@
</div> </div>
} }
</div> </div>
<CharacterFormModal
Visible="State.ShowCreateCharacterModal"
Title="Create Character"
SubmitLabel="Create Character"
NameInputId="character-create-name"
CampaignInputId="character-create-campaign"
OwnerUsernameInputId="character-create-owner"
InitialModel="State.CreateCharacterInitialModel"
FormVersion="State.CreateCharacterFormVersion"
EditingCharacterId="null"
CampaignOptions="State.CharacterCampaignOptions"
IsMutating="State.IsMutating"
AllowOwnerEdit="false"
AvailableUsernames="State.KnownUsernames"
CharacterSaved="Campaigns.OnCharacterCreatedAsync"
CancelRequested="Campaigns.CloseCharacterModals"/>
<CharacterFormModal
Visible="State.ShowEditCharacterModal"
Title="Edit Character"
SubmitLabel="Save Character"
NameInputId="character-edit-name"
CampaignInputId="character-edit-campaign"
OwnerUsernameInputId="character-edit-owner"
InitialModel="State.EditCharacterInitialModel"
FormVersion="State.EditCharacterFormVersion"
EditingCharacterId="State.EditingCharacterId"
CampaignOptions="State.CharacterCampaignOptions"
IsMutating="State.IsMutating"
AllowOwnerEdit="State.CanEditCharacterOwner"
AvailableUsernames="State.KnownUsernames"
CharacterSaved="Campaigns.OnCharacterUpdatedAsync"
CancelRequested="Campaigns.CloseCharacterModals"/>
<RolemasterSkillRollModal
Visible="State.ShowRolemasterSkillRollModal"
SkillName="@(State.PendingRolemasterSkillRoll?.Name ?? string.Empty)"
Expression="@(State.PendingRolemasterSkillRoll?.DiceRollDefinition ?? string.Empty)"
ModifierText="@State.PendingRolemasterSituationalModifier"
ModifierTextChanged="@(text => State.PendingRolemasterSituationalModifier = text)"
ErrorMessage="@State.PendingRolemasterSkillRollError"
IsMutating="State.IsMutating"
IsSubmitting="State.IsSubmittingRolemasterSkillRoll"
ConfirmRequested="Play.SubmitRolemasterSkillRollAsync"
CancelRequested="Play.CancelRolemasterSkillRollAsync"/>

View File

@@ -121,6 +121,11 @@ public partial class Workspace : IAsyncDisposable
return Task.CompletedTask; return Task.CompletedTask;
} }
private Task RequestRefreshAsync()
{
return InvokeAsync(StateHasChanged);
}
private static bool IsStaticRenderInteropException(InvalidOperationException exception) private static bool IsStaticRenderInteropException(InvalidOperationException exception)
{ {
return exception.Message.Contains("statically rendered", StringComparison.OrdinalIgnoreCase); return exception.Message.Contains("statically rendered", StringComparison.OrdinalIgnoreCase);
@@ -136,6 +141,7 @@ public partial class Workspace : IAsyncDisposable
[Parameter] public EventCallback<string?> LoggedOut { get; set; } [Parameter] public EventCallback<string?> LoggedOut { get; set; }
[Parameter] public WorkspaceRoute Route { get; set; } = WorkspaceRoute.Play; [Parameter] public WorkspaceRoute Route { get; set; } = WorkspaceRoute.Play;
[Parameter] public RenderFragment<WorkspacePageContext>? ChildContent { get; set; }
private WorkspaceState State { get; } = new(); private WorkspaceState State { get; } = new();
private bool HasSessionInitialized { get; set; } private bool HasSessionInitialized { get; set; }
@@ -146,6 +152,10 @@ public partial class Workspace : IAsyncDisposable
private bool IsAdminRoute => Route == WorkspaceRoute.Admin; private bool IsAdminRoute => Route == WorkspaceRoute.Admin;
private string AppCssClass => IsPlayRoute ? "rr-app app-play" : "rr-app"; 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, private WorkspaceCampaignScopeCoordinator Scope => m_Scope ??= new(State, Feedback, JS, WorkspaceQuery,
() => IsPlayRoute, Play.EnsureSelectedCharacterActiveAsync, Play.RefreshSelectedCharacterSheetAsync, () => IsPlayRoute, Play.EnsureSelectedCharacterActiveAsync, Play.RefreshSelectedCharacterSheetAsync,
Play.RefreshCampaignLogAsync, Play.ResetCampaignLogDetailState, Play.ResetCampaignStateTracking, Play.RefreshCampaignLogAsync, Play.ResetCampaignLogDetailState, Play.ResetCampaignStateTracking,

View File

@@ -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<Task> requestRefreshAsync,
bool enableCharacterControls,
bool enableCustomRollComposer,
string adminDatabaseDownloadUrl,
IReadOnlyList<AppHeaderMenuItem> 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<Task> RequestRefreshAsync { get; } = requestRefreshAsync;
public bool EnableCharacterControls { get; } = enableCharacterControls;
public bool EnableCustomRollComposer { get; } = enableCustomRollComposer;
public string AdminDatabaseDownloadUrl { get; } = adminDatabaseDownloadUrl;
public IReadOnlyList<AppHeaderMenuItem> HeaderMenuItems { get; } = headerMenuItems;
public bool IsPlayRoute { get; } = isPlayRoute;
public bool IsCampaignsRoute { get; } = isCampaignsRoute;
public bool IsAdminRoute { get; } = isAdminRoute;
}

View File

@@ -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 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) 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. - [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. - [ ] 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. - [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 repositorys 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 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. 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 ## Context and Orientation