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"
@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"
@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"
@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"
ToggleMenuRequested="ToggleScreenMenu"
LogoutRequested="Session.LogoutAsync"/>
@if (IsPlayRoute)
@if (ChildContent is not null)
{
<main class="play-screen @(State.MobilePanel == "log" ? "mobile-log" : "mobile-character")">
<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>
@ChildContent(PageContext)
}
</div>
@@ -185,49 +45,3 @@
</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;
}
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<string?> LoggedOut { get; set; }
[Parameter] public WorkspaceRoute Route { get; set; } = WorkspaceRoute.Play;
[Parameter] public RenderFragment<WorkspacePageContext>? 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,

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