From 4af1c876395b5fcbe672f7399d747a16b084bac7 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Sun, 5 Apr 2026 00:03:21 +0200 Subject: [PATCH] Extract workspace session coordinator --- README.md | 2 +- RpgRoller/Components/Pages/Workspace.razor.cs | 256 ++------------- .../Pages/WorkspaceSessionCoordinator.cs | 303 ++++++++++++++++++ 3 files changed, 329 insertions(+), 232 deletions(-) create mode 100644 RpgRoller/Components/Pages/WorkspaceSessionCoordinator.cs diff --git a/README.md b/README.md index 57d55c4..2686c61 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Frontend: - `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/Workspace.razor`: authenticated workspace UI and workspace-specific state/logic -- `RpgRoller/Components/Pages/WorkspaceState.cs`, `WorkspaceToast.cs`, and `WorkspaceFeedbackService.cs`: extracted workspace UI state, toast records, and feedback/status handling while `Workspace` remains the behavior owner +- `RpgRoller/Components/Pages/WorkspaceState.cs`, `WorkspaceToast.cs`, `WorkspaceFeedbackService.cs`, and `WorkspaceSessionCoordinator.cs`: extracted workspace UI state, toast records, feedback/status handling, and session/bootstrap orchestration 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/Pages/Home.Models.cs`: reusable `FormState` + page form models - `RpgRoller/Components/Pages/HomeControls/`: auth, campaign management, play-screen, and modal controls extracted from `Home.razor` diff --git a/RpgRoller/Components/Pages/Workspace.razor.cs b/RpgRoller/Components/Pages/Workspace.razor.cs index df9b478..aa2a574 100644 --- a/RpgRoller/Components/Pages/Workspace.razor.cs +++ b/RpgRoller/Components/Pages/Workspace.razor.cs @@ -16,114 +16,18 @@ public partial class Workspace : IAsyncDisposable if (!firstRender) return; - await InitializeAsync(); + await Session.InitializeAsync(); await InvokeAsync(StateHasChanged); } - private async Task InitializeAsync() + private Task RetryAfterHealthIssueAsync() { - var storedScreen = await JS.InvokeAsync("rpgRollerApi.getSessionValue", ScreenSessionKey); - CurrentScreen = NormalizeRequestedScreen(storedScreen) ?? ScreenPlay; - - var storedPanel = await JS.InvokeAsync("rpgRollerApi.getSessionValue", MobilePanelSessionKey); - if (string.Equals(storedPanel, "log", StringComparison.OrdinalIgnoreCase)) - MobilePanel = "log"; - - var storedRollVisibility = await JS.InvokeAsync("rpgRollerApi.getSessionValue", RollVisibilitySessionKey); - RollVisibility = NormalizeRollVisibility(storedRollVisibility); - - Guid? preferredCampaignId = null; - var storedCampaignId = await JS.InvokeAsync("rpgRollerApi.getSessionValue", CampaignSessionKey); - if (Guid.TryParse(storedCampaignId, out var parsedCampaignId)) - preferredCampaignId = parsedCampaignId; - - await CheckHealthAsync(); - await LoadRulesetsAsync(); - - var reloaded = await ReloadAuthenticatedSessionAsync(preferredCampaignId); - if (!reloaded) - await LoggedOut.InvokeAsync("Session expired. Please log in again."); + return Session.RetryAfterHealthIssueAsync(); } - private async Task RetryAfterHealthIssueAsync() + private Task LoadKnownUsernamesAsync() { - await CheckHealthAsync(); - if (!HasHealthIssue && User is not null) - { - var reloaded = await ReloadAuthenticatedSessionAsync(SelectedCampaignId); - if (!reloaded) - await LoggedOut.InvokeAsync("Session expired. Please log in again."); - } - } - - private async Task CheckHealthAsync() - { - HasHealthIssue = false; - HealthIssueMessage = string.Empty; - await Task.CompletedTask; - } - - private async Task LoadRulesetsAsync() - { - try - { - Rulesets = (await WorkspaceQuery.GetRulesetsAsync()).ToList(); - } - catch (ApiRequestException ex) - { - SetStatus(ex.Message, true); - } - } - - private async Task LoadKnownUsernamesAsync() - { - try - { - var usernames = await WorkspaceQuery.GetUsernamesAsync(); - KnownUsernames = usernames.OrderBy(username => username, StringComparer.OrdinalIgnoreCase).ToList(); - } - catch (ApiRequestException ex) - { - KnownUsernames = []; - SetStatus(ex.Message, true); - } - } - - private async Task ReloadAuthenticatedSessionAsync(Guid? preferredCampaignId) - { - var me = await TryGetMeAsync(); - if (me is null) - { - ClearAuthenticatedState(); - await StopStateEventsAsync(); - return false; - } - - User = me.User; - ActiveCharacterId = me.ActiveCharacterId; - await EnsureScreenAccessAsync(); - - await ReloadCampaignsAsync(preferredCampaignId ?? me.CurrentCampaignId); - await ReloadCharacterCampaignOptionsAsync(); - await RefreshCampaignScopeAsync(); - await SyncStateEventsAsync(); - - if (IsAdminScreen) - await EnsureAdminUsersLoadedAsync(); - - return true; - } - - private async Task TryGetMeAsync() - { - try - { - return await WorkspaceQuery.GetMeAsync(); - } - catch (ApiRequestException ex) when (ex.StatusCode == 401) - { - return null; - } + return Session.LoadKnownUsernamesAsync(); } private async Task ReloadCampaignsAsync(Guid? preferredCampaignId) @@ -263,52 +167,9 @@ public partial class Workspace : IAsyncDisposable } } - private async Task LogoutAsync() - { - if (IsMutating) - return; + private Task LogoutAsync() => Session.LogoutAsync(); - IsMutating = true; - try - { - await ApiClient.RequestWithoutPayloadAsync("POST", "/api/auth/logout"); - } - catch (ApiRequestException) - { - } - finally - { - IsMutating = false; - } - - ClearAuthenticatedState(); - await StopStateEventsAsync(); - await LoggedOut.InvokeAsync("Logged out."); - } - - private async Task SwitchScreenAsync(string screen) - { - var targetScreen = NormalizeRequestedScreen(screen) ?? ScreenPlay; - if (string.Equals(targetScreen, ScreenAdmin, StringComparison.OrdinalIgnoreCase) && !IsCurrentUserAdmin) - targetScreen = ScreenPlay; - - CurrentScreen = targetScreen; - IsScreenMenuOpen = false; - await PersistScreenPreferenceAsync(CurrentScreen); - await InvokeAsync(StateHasChanged); - - if (User is not null) - { - await RefreshCampaignScopeAsync(); - await SyncStateEventsAsync(); - } - - if (IsAdminScreen) - { - await EnsureAdminUsersLoadedAsync(); - await InvokeAsync(StateHasChanged); - } - } + private Task SwitchScreenAsync(string screen) => Session.SwitchScreenAsync(screen); private Task SwitchToPlayAsync() { @@ -325,21 +186,6 @@ public partial class Workspace : IAsyncDisposable return SwitchScreenAsync(ScreenAdmin); } - private async Task EnsureScreenAccessAsync() - { - if (IsCurrentUserAdmin) - return; - - AdminUsers = []; - HasLoadedAdminUsers = false; - - if (!IsAdminScreen) - return; - - CurrentScreen = ScreenPlay; - await PersistScreenPreferenceAsync(CurrentScreen); - } - private async Task EnsureAdminUsersLoadedAsync() { if (!IsCurrentUserAdmin || HasLoadedAdminUsers || IsAdminDataLoading) @@ -762,44 +608,7 @@ public partial class Workspace : IAsyncDisposable Announce("Roll result updated."); } - private async Task OnRollVisibilityChanged(string visibility) - { - RollVisibility = NormalizeRollVisibility(visibility); - await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", RollVisibilitySessionKey, RollVisibility); - } - - private static string NormalizeRollVisibility(string? visibility) - { - 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, "management", StringComparison.OrdinalIgnoreCase)) - return "management"; - - if (string.Equals(screen, ScreenPlay, StringComparison.OrdinalIgnoreCase)) - return ScreenPlay; - - return null; - } - - private async Task PersistScreenPreferenceAsync(string screen) - { - try - { - await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", ScreenSessionKey, screen); - } - catch (JSDisconnectedException) - { - } - catch (InvalidOperationException ex) when (IsStaticRenderInteropException(ex)) - { - } - } + private Task OnRollVisibilityChanged(string visibility) => Session.OnRollVisibilityChangedAsync(visibility); private bool CanEditSkill(CharacterSheetSkill skill) { @@ -1051,34 +860,7 @@ public partial class Workspace : IAsyncDisposable FreshCampaignLogRollId = rollId; } - private void ClearAuthenticatedState() - { - User = null; - ActiveCharacterId = null; - SelectedCampaignId = null; - SelectedCampaign = null; - Campaigns = []; - CharacterCampaignOptions = []; - SelectedCharacterSkills = []; - SelectedCharacterSkillGroups = []; - CampaignLog = []; - CampaignLogCursor = null; - ResetCampaignLogDetailState(); - SelectedCharacterId = null; - LastRoll = null; - KnownUsernames = []; - ShowCreateCharacterModal = false; - ShowEditCharacterModal = false; - CanEditCharacterOwner = false; - CreateCharacterInitialModel = new(); - EditCharacterInitialModel = new(); - CreateCharacterFormVersion = 0; - EditCharacterFormVersion = 0; - AdminUsers = []; - HasLoadedAdminUsers = false; - IsAdminDataLoading = false; - Feedback.ClearToasts(); - } + private void ClearAuthenticatedState() => Session.ClearAuthenticatedState(); private void SetStatus(string message, bool isError) { @@ -1197,6 +979,21 @@ public partial class Workspace : IAsyncDisposable private bool IsManagementScreen => State.IsManagementScreen; private bool IsAdminScreen => State.IsAdminScreen; private WorkspaceFeedbackService Feedback => m_Feedback ??= new(State, () => InvokeAsync(StateHasChanged)); + private WorkspaceSessionCoordinator Session => m_Session ??= new( + State, + Feedback, + JS, + ApiClient, + WorkspaceQuery, + ReloadCampaignsAsync, + ReloadCharacterCampaignOptionsAsync, + RefreshCampaignScopeAsync, + SyncStateEventsAsync, + StopStateEventsAsync, + EnsureAdminUsersLoadedAsync, + ResetCampaignLogDetailState, + () => InvokeAsync(StateHasChanged), + message => LoggedOut.InvokeAsync(message)); private IReadOnlyList HeaderMenuItems { get @@ -1219,14 +1016,11 @@ public partial class Workspace : IAsyncDisposable private string AppCssClass => State.AppCssClass; private string AdminDatabaseDownloadUrl => Navigation.ToAbsoluteUri("api/admin/database").ToString(); - 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"; private const int CampaignLogWindowSize = 25; private WorkspaceFeedbackService? m_Feedback; + private WorkspaceSessionCoordinator? m_Session; } diff --git a/RpgRoller/Components/Pages/WorkspaceSessionCoordinator.cs b/RpgRoller/Components/Pages/WorkspaceSessionCoordinator.cs new file mode 100644 index 0000000..f4d2795 --- /dev/null +++ b/RpgRoller/Components/Pages/WorkspaceSessionCoordinator.cs @@ -0,0 +1,303 @@ +using Microsoft.JSInterop; +using RpgRoller.Contracts; + +namespace RpgRoller.Components.Pages; + +public sealed class WorkspaceSessionCoordinator +{ + public 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) + { + m_State = state; + m_Feedback = feedback; + m_JS = js; + m_ApiClient = apiClient; + m_WorkspaceQuery = workspaceQuery; + m_ReloadCampaignsAsync = reloadCampaignsAsync; + m_ReloadCharacterCampaignOptionsAsync = reloadCharacterCampaignOptionsAsync; + m_RefreshCampaignScopeAsync = refreshCampaignScopeAsync; + m_SyncStateEventsAsync = syncStateEventsAsync; + m_StopStateEventsAsync = stopStateEventsAsync; + m_EnsureAdminUsersLoadedAsync = ensureAdminUsersLoadedAsync; + m_ResetCampaignLogDetailState = resetCampaignLogDetailState; + m_RequestRefreshAsync = requestRefreshAsync; + m_OnLoggedOutAsync = onLoggedOutAsync; + } + + public async Task InitializeAsync() + { + var storedScreen = await m_JS.InvokeAsync("rpgRollerApi.getSessionValue", ScreenSessionKey); + m_State.CurrentScreen = NormalizeRequestedScreen(storedScreen) ?? ScreenPlay; + + var storedPanel = await m_JS.InvokeAsync("rpgRollerApi.getSessionValue", MobilePanelSessionKey); + if (string.Equals(storedPanel, "log", StringComparison.OrdinalIgnoreCase)) + m_State.MobilePanel = "log"; + + var storedRollVisibility = await m_JS.InvokeAsync("rpgRollerApi.getSessionValue", RollVisibilitySessionKey); + m_State.RollVisibility = NormalizeRollVisibility(storedRollVisibility); + + Guid? preferredCampaignId = null; + var storedCampaignId = await m_JS.InvokeAsync("rpgRollerApi.getSessionValue", CampaignSessionKey); + if (Guid.TryParse(storedCampaignId, out var parsedCampaignId)) + preferredCampaignId = parsedCampaignId; + + await CheckHealthAsync(); + await LoadRulesetsAsync(); + + var reloaded = await ReloadAuthenticatedSessionAsync(preferredCampaignId); + if (!reloaded) + await m_OnLoggedOutAsync("Session expired. Please log in again."); + } + + public async Task RetryAfterHealthIssueAsync() + { + await CheckHealthAsync(); + if (!m_State.HasHealthIssue && m_State.User is not null) + { + var reloaded = await ReloadAuthenticatedSessionAsync(m_State.SelectedCampaignId); + if (!reloaded) + await m_OnLoggedOutAsync("Session expired. Please log in again."); + } + } + + public async Task LoadKnownUsernamesAsync() + { + try + { + var usernames = await m_WorkspaceQuery.GetUsernamesAsync(); + m_State.KnownUsernames = usernames.OrderBy(username => username, StringComparer.OrdinalIgnoreCase).ToList(); + } + catch (ApiRequestException ex) + { + m_State.KnownUsernames = []; + m_Feedback.SetStatus(ex.Message, true); + } + } + + public async Task LogoutAsync() + { + if (m_State.IsMutating) + return; + + m_State.IsMutating = true; + try + { + await m_ApiClient.RequestWithoutPayloadAsync("POST", "/api/auth/logout"); + } + catch (ApiRequestException) + { + } + finally + { + m_State.IsMutating = false; + } + + ClearAuthenticatedState(); + await m_StopStateEventsAsync(); + await m_OnLoggedOutAsync("Logged out."); + } + + public async Task SwitchScreenAsync(string screen) + { + var targetScreen = NormalizeRequestedScreen(screen) ?? ScreenPlay; + if (string.Equals(targetScreen, ScreenAdmin, StringComparison.OrdinalIgnoreCase) && !m_State.IsCurrentUserAdmin) + targetScreen = ScreenPlay; + + m_State.CurrentScreen = targetScreen; + m_State.IsScreenMenuOpen = false; + await PersistScreenPreferenceAsync(m_State.CurrentScreen); + await m_RequestRefreshAsync(); + + if (m_State.User is not null) + { + await m_RefreshCampaignScopeAsync(); + await m_SyncStateEventsAsync(); + } + + if (m_State.IsAdminScreen) + { + await m_EnsureAdminUsersLoadedAsync(); + await m_RequestRefreshAsync(); + } + } + + public async Task OnRollVisibilityChangedAsync(string visibility) + { + m_State.RollVisibility = NormalizeRollVisibility(visibility); + await m_JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", RollVisibilitySessionKey, m_State.RollVisibility); + } + + public void ClearAuthenticatedState() + { + m_State.User = null; + m_State.ActiveCharacterId = null; + m_State.SelectedCampaignId = null; + m_State.SelectedCampaign = null; + m_State.Campaigns = []; + m_State.CharacterCampaignOptions = []; + m_State.SelectedCharacterSkills = []; + m_State.SelectedCharacterSkillGroups = []; + m_State.CampaignLog = []; + m_State.CampaignLogCursor = null; + m_ResetCampaignLogDetailState(); + m_State.SelectedCharacterId = null; + m_State.LastRoll = null; + m_State.KnownUsernames = []; + m_State.ShowCreateCharacterModal = false; + m_State.ShowEditCharacterModal = false; + m_State.CanEditCharacterOwner = false; + m_State.CreateCharacterInitialModel = new(); + m_State.EditCharacterInitialModel = new(); + m_State.CreateCharacterFormVersion = 0; + m_State.EditCharacterFormVersion = 0; + m_State.AdminUsers = []; + m_State.HasLoadedAdminUsers = false; + m_State.IsAdminDataLoading = false; + m_Feedback.ClearToasts(); + } + + private async Task CheckHealthAsync() + { + m_State.HasHealthIssue = false; + m_State.HealthIssueMessage = string.Empty; + await Task.CompletedTask; + } + + private async Task LoadRulesetsAsync() + { + try + { + m_State.Rulesets = (await m_WorkspaceQuery.GetRulesetsAsync()).ToList(); + } + catch (ApiRequestException ex) + { + m_Feedback.SetStatus(ex.Message, true); + } + } + + private async Task ReloadAuthenticatedSessionAsync(Guid? preferredCampaignId) + { + var me = await TryGetMeAsync(); + if (me is null) + { + ClearAuthenticatedState(); + await m_StopStateEventsAsync(); + return false; + } + + m_State.User = me.User; + m_State.ActiveCharacterId = me.ActiveCharacterId; + await EnsureScreenAccessAsync(); + + await m_ReloadCampaignsAsync(preferredCampaignId ?? me.CurrentCampaignId); + await m_ReloadCharacterCampaignOptionsAsync(); + await m_RefreshCampaignScopeAsync(); + await m_SyncStateEventsAsync(); + + if (m_State.IsAdminScreen) + await m_EnsureAdminUsersLoadedAsync(); + + return true; + } + + private async Task TryGetMeAsync() + { + try + { + return await m_WorkspaceQuery.GetMeAsync(); + } + catch (ApiRequestException ex) when (ex.StatusCode == 401) + { + return null; + } + } + + private async Task EnsureScreenAccessAsync() + { + if (m_State.IsCurrentUserAdmin) + return; + + m_State.AdminUsers = []; + m_State.HasLoadedAdminUsers = false; + + if (!m_State.IsAdminScreen) + return; + + m_State.CurrentScreen = ScreenPlay; + await PersistScreenPreferenceAsync(m_State.CurrentScreen); + } + + private async Task PersistScreenPreferenceAsync(string screen) + { + try + { + await m_JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", ScreenSessionKey, screen); + } + catch (JSDisconnectedException) + { + } + catch (InvalidOperationException ex) when (IsStaticRenderInteropException(ex)) + { + } + } + + private static string NormalizeRollVisibility(string? visibility) + { + 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 readonly RpgRollerApiClient m_ApiClient; + private readonly WorkspaceFeedbackService m_Feedback; + private readonly Func m_EnsureAdminUsersLoadedAsync; + private readonly IJSRuntime m_JS; + private readonly Func m_OnLoggedOutAsync; + private readonly Func m_ReloadCharacterCampaignOptionsAsync; + private readonly Func m_ReloadCampaignsAsync; + private readonly Action m_ResetCampaignLogDetailState; + private readonly Func m_RefreshCampaignScopeAsync; + private readonly Func m_RequestRefreshAsync; + private readonly WorkspaceState m_State; + private readonly Func m_StopStateEventsAsync; + private readonly Func m_SyncStateEventsAsync; + private readonly WorkspaceQueryService m_WorkspaceQuery; + + 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"; +}