Extract workspace campaign coordinator

This commit is contained in:
2026-04-05 00:12:19 +02:00
parent abee1729c5
commit ec40baa107
3 changed files with 216 additions and 139 deletions

View File

@@ -35,7 +35,7 @@ Frontend:
- `RpgRoller/Components/Pages/Home.razor`: minimal gateway page (loading/auth/workspace switch) - `RpgRoller/Components/Pages/Home.razor`: minimal gateway page (loading/auth/workspace switch)
- `RpgRoller/Components/Pages/Home.razor.cs`: single `Home` code-behind with only gateway/session-view orchestration - `RpgRoller/Components/Pages/Home.razor.cs`: single `Home` code-behind with only gateway/session-view orchestration
- `RpgRoller/Components/Pages/Workspace.razor`: authenticated workspace UI and workspace-specific state/logic - `RpgRoller/Components/Pages/Workspace.razor`: authenticated workspace UI and workspace-specific state/logic
- `RpgRoller/Components/Pages/WorkspaceState.cs`, `WorkspaceToast.cs`, `WorkspaceFeedbackService.cs`, `WorkspaceSessionCoordinator.cs`, and `WorkspaceAdminCoordinator.cs`: extracted workspace UI state, toast records, feedback/status handling, session/bootstrap orchestration, and admin user actions while `Workspace` remains the behavior owner - `RpgRoller/Components/Pages/WorkspaceState.cs`, `WorkspaceToast.cs`, `WorkspaceFeedbackService.cs`, `WorkspaceSessionCoordinator.cs`, `WorkspaceAdminCoordinator.cs`, and `WorkspaceCampaignCoordinator.cs`: extracted workspace UI state, toast records, feedback/status handling, session/bootstrap orchestration, admin user actions, and campaign-management/modal flows while `Workspace` remains the behavior owner
- `RpgRoller/Components/**/*.razor.cs`: component code-behind classes (state, handlers, parameters, injected dependencies); `.razor` files remain markup-focused - `RpgRoller/Components/**/*.razor.cs`: component code-behind classes (state, handlers, parameters, injected dependencies); `.razor` files remain markup-focused
- `RpgRoller/Components/Pages/Home.Models.cs`: reusable `FormState<TModel>` + page form models - `RpgRoller/Components/Pages/Home.Models.cs`: reusable `FormState<TModel>` + page form models
- `RpgRoller/Components/Pages/HomeControls/`: auth, campaign management, play-screen, and modal controls extracted from `Home.razor` - `RpgRoller/Components/Pages/HomeControls/`: auth, campaign management, play-screen, and modal controls extracted from `Home.razor`

View File

@@ -208,144 +208,23 @@ public partial class Workspace : IAsyncDisposable
return SetMobilePanelAsync("log"); return SetMobilePanelAsync("log");
} }
private async Task OnCampaignSelectionChangedAsync(ChangeEventArgs args) private Task OnCampaignSelectionChangedAsync(ChangeEventArgs args) => CampaignsFlow.OnCampaignSelectionChangedAsync(args);
{
if (!Guid.TryParse(args.Value?.ToString(), out var campaignId))
return;
SelectedCampaignId = campaignId; private Task OnCampaignCreatedAsync(Guid campaignId) => CampaignsFlow.OnCampaignCreatedAsync(campaignId);
await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, campaignId.ToString());
await RefreshCampaignScopeAsync();
await SyncStateEventsAsync();
IsScreenMenuOpen = false;
}
private async Task OnCampaignCreatedAsync(Guid campaignId) private void OpenCreateCharacterModal() => CampaignsFlow.OpenCreateCharacterModal();
{
await ReloadCampaignsAsync(campaignId);
await ReloadCharacterCampaignOptionsAsync();
await RefreshCampaignScopeAsync();
await SyncStateEventsAsync();
SetStatus("Campaign created.", false);
}
private void OpenCreateCharacterModal() private Task OpenEditCharacterModal(CharacterSummary character) => CampaignsFlow.OpenEditCharacterModal(character);
{
CreateCharacterInitialModel = new()
{
Name = string.Empty,
CampaignId = SelectedCampaignId?.ToString() ?? CharacterCampaignOptions.FirstOrDefault()?.Id.ToString() ?? string.Empty,
OwnerUsername = string.Empty
};
CreateCharacterFormVersion++; private void CloseCharacterModals() => CampaignsFlow.CloseCharacterModals();
CanEditCharacterOwner = false;
ShowCreateCharacterModal = true;
}
private async Task OpenEditCharacterModal(CharacterSummary character) private Task OnCharacterCreatedAsync(Guid? campaignId) => CampaignsFlow.OnCharacterCreatedAsync(campaignId);
{
if (IsCurrentUserGm || IsCurrentUserAdmin)
await LoadKnownUsernamesAsync();
EditingCharacterId = character.Id; private Task OnCharacterUpdatedAsync(Guid? campaignId) => CampaignsFlow.OnCharacterUpdatedAsync(campaignId);
EditCharacterInitialModel = new()
{
Name = character.Name,
CampaignId = character.CampaignId?.ToString() ?? string.Empty,
OwnerUsername = string.Empty
};
EditCharacterFormVersion++; private Task DeleteSelectedCampaignAsync() => CampaignsFlow.DeleteSelectedCampaignAsync();
CanEditCharacterOwner = IsCurrentUserGm || IsCurrentUserAdmin;
ShowEditCharacterModal = true;
}
private void CloseCharacterModals() private Task DeleteCharacterAsync(CharacterSummary character) => CampaignsFlow.DeleteCharacterAsync(character);
{
ShowCreateCharacterModal = false;
ShowEditCharacterModal = false;
CanEditCharacterOwner = false;
EditingCharacterId = null;
}
private async Task OnCharacterCreatedAsync(Guid? campaignId)
{
CloseCharacterModals();
await ReloadCampaignsAsync(campaignId);
await ReloadCharacterCampaignOptionsAsync();
await RefreshCampaignScopeAsync();
await SyncStateEventsAsync();
SetStatus("Character created.", false);
}
private async Task OnCharacterUpdatedAsync(Guid? campaignId)
{
CloseCharacterModals();
await ReloadCampaignsAsync(campaignId);
await ReloadCharacterCampaignOptionsAsync();
await RefreshCampaignScopeAsync();
await SyncStateEventsAsync();
SetStatus(campaignId.HasValue ? "Character updated." : "Character unlinked from campaign.", false);
}
private async Task DeleteSelectedCampaignAsync()
{
if (SelectedCampaign is null || IsMutating || !CanDeleteSelectedCampaign)
return;
var confirmed = await JS.InvokeAsync<bool>("confirm", $"Delete campaign '{SelectedCampaign.Name}'?");
if (!confirmed)
return;
IsMutating = true;
try
{
_ = await ApiClient.RequestAsync<bool>("DELETE", $"/api/campaigns/{SelectedCampaign.Id}");
await ReloadCampaignsAsync(null);
await ReloadCharacterCampaignOptionsAsync();
await RefreshCampaignScopeAsync();
await SyncStateEventsAsync();
SetStatus("Campaign deleted.", false);
}
catch (ApiRequestException ex)
{
SetStatus(ex.Message, true);
}
finally
{
IsMutating = false;
}
}
private async Task DeleteCharacterAsync(CharacterSummary character)
{
if (IsMutating || !CanDeleteCharacter(character))
return;
var confirmed = await JS.InvokeAsync<bool>("confirm", $"Delete character '{character.Name}'?");
if (!confirmed)
return;
IsMutating = true;
try
{
_ = await ApiClient.RequestAsync<bool>("DELETE", $"/api/characters/{character.Id}");
await ReloadCampaignsAsync(SelectedCampaignId);
await ReloadCharacterCampaignOptionsAsync();
await RefreshCampaignScopeAsync();
await SyncStateEventsAsync();
SetStatus("Character deleted.", false);
}
catch (ApiRequestException ex)
{
SetStatus(ex.Message, true);
}
finally
{
IsMutating = false;
}
}
private async Task SelectCharacterAsync(Guid characterId) private async Task SelectCharacterAsync(Guid characterId)
{ {
@@ -354,15 +233,9 @@ public partial class Workspace : IAsyncDisposable
await EnsureSelectedCharacterActiveAsync(); await EnsureSelectedCharacterActiveAsync();
} }
private bool CanEditCharacter(CharacterSummary character) private bool CanEditCharacter(CharacterSummary character) => CampaignsFlow.CanEditCharacter(character);
{
return User is not null && (character.OwnerUserId == User.Id || IsCurrentUserGm || IsCurrentUserAdmin);
}
private bool CanDeleteCharacter(CharacterSummary character) private bool CanDeleteCharacter(CharacterSummary character) => CampaignsFlow.CanDeleteCharacter(character);
{
return User is not null && (character.OwnerUserId == User.Id || IsCurrentUserAdmin);
}
private static bool CanActivateCharacter(CharacterSummary character, UserSummary? user) private static bool CanActivateCharacter(CharacterSummary character, UserSummary? user)
{ {
@@ -891,6 +764,16 @@ public partial class Workspace : IAsyncDisposable
private bool IsPlayScreen => State.IsPlayScreen; private bool IsPlayScreen => State.IsPlayScreen;
private bool IsManagementScreen => State.IsManagementScreen; private bool IsManagementScreen => State.IsManagementScreen;
private bool IsAdminScreen => State.IsAdminScreen; private bool IsAdminScreen => State.IsAdminScreen;
private WorkspaceCampaignCoordinator CampaignsFlow => m_CampaignsFlow ??= new(
State,
Feedback,
JS,
ApiClient,
LoadKnownUsernamesAsync,
ReloadCampaignsAsync,
ReloadCharacterCampaignOptionsAsync,
RefreshCampaignScopeAsync,
SyncStateEventsAsync);
private WorkspaceAdminCoordinator Admin => m_Admin ??= new( private WorkspaceAdminCoordinator Admin => m_Admin ??= new(
State, State,
Feedback, Feedback,
@@ -943,6 +826,7 @@ public partial class Workspace : IAsyncDisposable
private const string MobilePanelSessionKey = "play-panel"; private const string MobilePanelSessionKey = "play-panel";
private const int CampaignLogWindowSize = 25; private const int CampaignLogWindowSize = 25;
private WorkspaceCampaignCoordinator? m_CampaignsFlow;
private WorkspaceAdminCoordinator? m_Admin; private WorkspaceAdminCoordinator? m_Admin;
private WorkspaceFeedbackService? m_Feedback; private WorkspaceFeedbackService? m_Feedback;
private WorkspaceSessionCoordinator? m_Session; private WorkspaceSessionCoordinator? m_Session;

View File

@@ -0,0 +1,193 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage]
public sealed class WorkspaceCampaignCoordinator
{
public WorkspaceCampaignCoordinator(
WorkspaceState state,
WorkspaceFeedbackService feedback,
IJSRuntime js,
RpgRollerApiClient apiClient,
Func<Task> loadKnownUsernamesAsync,
Func<Guid?, Task> reloadCampaignsAsync,
Func<Task> reloadCharacterCampaignOptionsAsync,
Func<Task> refreshCampaignScopeAsync,
Func<Task> syncStateEventsAsync)
{
m_State = state;
m_Feedback = feedback;
m_JS = js;
m_ApiClient = apiClient;
m_LoadKnownUsernamesAsync = loadKnownUsernamesAsync;
m_ReloadCampaignsAsync = reloadCampaignsAsync;
m_ReloadCharacterCampaignOptionsAsync = reloadCharacterCampaignOptionsAsync;
m_RefreshCampaignScopeAsync = refreshCampaignScopeAsync;
m_SyncStateEventsAsync = syncStateEventsAsync;
}
public async Task OnCampaignSelectionChangedAsync(ChangeEventArgs args)
{
if (!Guid.TryParse(args.Value?.ToString(), out var campaignId))
return;
m_State.SelectedCampaignId = campaignId;
await m_JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, campaignId.ToString());
await m_RefreshCampaignScopeAsync();
await m_SyncStateEventsAsync();
m_State.IsScreenMenuOpen = false;
}
public async Task OnCampaignCreatedAsync(Guid campaignId)
{
await m_ReloadCampaignsAsync(campaignId);
await m_ReloadCharacterCampaignOptionsAsync();
await m_RefreshCampaignScopeAsync();
await m_SyncStateEventsAsync();
m_Feedback.SetStatus("Campaign created.", false);
}
public void OpenCreateCharacterModal()
{
m_State.CreateCharacterInitialModel = new()
{
Name = string.Empty,
CampaignId = m_State.SelectedCampaignId?.ToString() ?? m_State.CharacterCampaignOptions.FirstOrDefault()?.Id.ToString() ?? string.Empty,
OwnerUsername = string.Empty
};
m_State.CreateCharacterFormVersion += 1;
m_State.CanEditCharacterOwner = false;
m_State.ShowCreateCharacterModal = true;
}
public async Task OpenEditCharacterModal(CharacterSummary character)
{
if (m_State.IsCurrentUserGm || m_State.IsCurrentUserAdmin)
await m_LoadKnownUsernamesAsync();
m_State.EditingCharacterId = character.Id;
m_State.EditCharacterInitialModel = new()
{
Name = character.Name,
CampaignId = character.CampaignId?.ToString() ?? string.Empty,
OwnerUsername = string.Empty
};
m_State.EditCharacterFormVersion += 1;
m_State.CanEditCharacterOwner = m_State.IsCurrentUserGm || m_State.IsCurrentUserAdmin;
m_State.ShowEditCharacterModal = true;
}
public void CloseCharacterModals()
{
m_State.ShowCreateCharacterModal = false;
m_State.ShowEditCharacterModal = false;
m_State.CanEditCharacterOwner = false;
m_State.EditingCharacterId = null;
}
public async Task OnCharacterCreatedAsync(Guid? campaignId)
{
CloseCharacterModals();
await m_ReloadCampaignsAsync(campaignId);
await m_ReloadCharacterCampaignOptionsAsync();
await m_RefreshCampaignScopeAsync();
await m_SyncStateEventsAsync();
m_Feedback.SetStatus("Character created.", false);
}
public async Task OnCharacterUpdatedAsync(Guid? campaignId)
{
CloseCharacterModals();
await m_ReloadCampaignsAsync(campaignId);
await m_ReloadCharacterCampaignOptionsAsync();
await m_RefreshCampaignScopeAsync();
await m_SyncStateEventsAsync();
m_Feedback.SetStatus(campaignId.HasValue ? "Character updated." : "Character unlinked from campaign.", false);
}
public async Task DeleteSelectedCampaignAsync()
{
if (m_State.SelectedCampaign is null || m_State.IsMutating || !m_State.CanDeleteSelectedCampaign)
return;
var confirmed = await m_JS.InvokeAsync<bool>("confirm", $"Delete campaign '{m_State.SelectedCampaign.Name}'?");
if (!confirmed)
return;
m_State.IsMutating = true;
try
{
_ = await m_ApiClient.RequestAsync<bool>("DELETE", $"/api/campaigns/{m_State.SelectedCampaign.Id}");
await m_ReloadCampaignsAsync(null);
await m_ReloadCharacterCampaignOptionsAsync();
await m_RefreshCampaignScopeAsync();
await m_SyncStateEventsAsync();
m_Feedback.SetStatus("Campaign deleted.", false);
}
catch (ApiRequestException ex)
{
m_Feedback.SetStatus(ex.Message, true);
}
finally
{
m_State.IsMutating = false;
}
}
public async Task DeleteCharacterAsync(CharacterSummary character)
{
if (m_State.IsMutating || !CanDeleteCharacter(character))
return;
var confirmed = await m_JS.InvokeAsync<bool>("confirm", $"Delete character '{character.Name}'?");
if (!confirmed)
return;
m_State.IsMutating = true;
try
{
_ = await m_ApiClient.RequestAsync<bool>("DELETE", $"/api/characters/{character.Id}");
await m_ReloadCampaignsAsync(m_State.SelectedCampaignId);
await m_ReloadCharacterCampaignOptionsAsync();
await m_RefreshCampaignScopeAsync();
await m_SyncStateEventsAsync();
m_Feedback.SetStatus("Character deleted.", false);
}
catch (ApiRequestException ex)
{
m_Feedback.SetStatus(ex.Message, true);
}
finally
{
m_State.IsMutating = false;
}
}
public bool CanEditCharacter(CharacterSummary character)
{
return m_State.User is not null && (character.OwnerUserId == m_State.User.Id || m_State.IsCurrentUserGm || m_State.IsCurrentUserAdmin);
}
public bool CanDeleteCharacter(CharacterSummary character)
{
return m_State.User is not null && (character.OwnerUserId == m_State.User.Id || m_State.IsCurrentUserAdmin);
}
private readonly RpgRollerApiClient m_ApiClient;
private readonly WorkspaceFeedbackService m_Feedback;
private readonly IJSRuntime m_JS;
private readonly Func<Task> m_LoadKnownUsernamesAsync;
private readonly Func<Task> m_ReloadCharacterCampaignOptionsAsync;
private readonly Func<Guid?, Task> m_ReloadCampaignsAsync;
private readonly Func<Task> m_RefreshCampaignScopeAsync;
private readonly WorkspaceState m_State;
private readonly Func<Task> m_SyncStateEventsAsync;
private const string CampaignSessionKey = "campaign";
}