From df98f39c5448970148e6e3919bfe5c13f15dde78 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Thu, 26 Feb 2026 10:33:48 +0100 Subject: [PATCH] Extract workspace host and collapse Home partials --- FAQ.md | 3 +- FRONTEND_PROGRESS.md | 5 +- README.md | 7 +- RpgRoller/Components/Pages/Home.Api.cs | 21 +- RpgRoller/Components/Pages/Home.Auth.cs | 45 +- RpgRoller/Components/Pages/Home.Campaign.cs | 61 +- RpgRoller/Components/Pages/Home.Character.cs | 97 +- RpgRoller/Components/Pages/Home.Lifecycle.cs | 203 +--- .../Components/Pages/Home.Presentation.cs | 174 +--- RpgRoller/Components/Pages/Home.Realtime.cs | 94 +- RpgRoller/Components/Pages/Home.Skill.cs | 77 +- RpgRoller/Components/Pages/Home.State.cs | 108 +- RpgRoller/Components/Pages/Home.Validation.cs | 6 +- RpgRoller/Components/Pages/Home.razor | 157 +-- RpgRoller/Components/Pages/Home.razor.cs | 76 ++ RpgRoller/Components/Pages/Workspace.razor | 919 ++++++++++++++++++ TECH.md | 7 +- 17 files changed, 1032 insertions(+), 1028 deletions(-) create mode 100644 RpgRoller/Components/Pages/Workspace.razor diff --git a/FAQ.md b/FAQ.md index 359a1ec..4040d53 100644 --- a/FAQ.md +++ b/FAQ.md @@ -70,7 +70,8 @@ There is no separate activate button in Play. The selected character in the char ## Where did the Home page logic move after the refactor? -`Home.razor` now focuses on composition and delegates behavior to concern-based code-behind partials (`Home.Auth.cs`, `Home.Campaign.cs`, `Home.Character.cs`, `Home.Skill.cs`, etc.) plus dedicated UI controls under `Components/Pages/HomeControls/`. +`Home.razor` + `Home.razor.cs` now act as a small gateway that switches between loading, anonymous auth, and workspace views. +Authenticated application state and behavior were moved into `Components/Pages/Workspace.razor`, while reusable concern UI remains under `Components/Pages/HomeControls/`. ## Why is auth form state kept in `AuthSection` instead of `Home`? diff --git a/FRONTEND_PROGRESS.md b/FRONTEND_PROGRESS.md index 33f8405..3cec15b 100644 --- a/FRONTEND_PROGRESS.md +++ b/FRONTEND_PROGRESS.md @@ -7,8 +7,9 @@ Tracking against `UX.md` tasks and decisions. - Branch: `feature/blazor-frontend-rebuild-ux` - Runtime frontend stack: Blazor (`RpgRoller/Components/*`) + browser JS interop (`wwwroot/js/rpgroller-api.js`) - Legacy TypeScript frontend/runtime artifacts: removed -- Home page orchestration split by concern (`Home.*.cs` partials + `HomeControls/*`) to reduce merge churn and keep auth/campaign/character/skill flows isolated. -- Concern controls now own their local form state and mutation workflows; `Home` handles shared cross-control state refresh. +- Home was simplified to a minimal gateway (`Loading` / `Anonymous` / `Workspace`) in a single `Home.razor.cs` class. +- The authenticated workspace shell/state/behavior was moved to `Components/Pages/Workspace.razor`. +- Concern controls now own their local form state and mutation workflows; the workspace host handles shared cross-control state refresh. - Skill create/edit flow is now owned by `CharacterPanel` (where characters and their skills are presented together). ## UX Checklist diff --git a/README.md b/README.md index aaf5b90..063c708 100644 --- a/README.md +++ b/README.md @@ -26,13 +26,14 @@ Backend: Frontend: - `RpgRoller/Components/`: Blazor root app, routes, layout and page components -- `RpgRoller/Components/Pages/Home.razor`: top-level page composition using concern-focused child controls -- `RpgRoller/Components/Pages/Home.*.cs`: concern-based partial class split (`State`, `Lifecycle`, `Auth`, `Campaign`, `Character`, `Skill`, `Realtime`, `Api`, `Presentation`, `Validation`) +- `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/Home.Models.cs`: reusable `FormState` + page form models - `RpgRoller/Components/Pages/HomeControls/`: auth, campaign management, play-screen, and modal controls extracted from `Home.razor` - Form ownership model: controls own transient form/error state and execute their concern-specific API mutations directly - Skill create/edit workflow ownership: `CharacterPanel` (characters own skills in UI and behavior) -- `RpgRoller/Components/RpgRollerApiClient.cs`: shared browser API client used by `Home` and leaf controls +- `RpgRoller/Components/RpgRollerApiClient.cs`: shared browser API client used by `Home`, `Workspace`, and leaf controls - `RpgRoller/wwwroot/js/rpgroller-api.js`: browser-side API + SSE + session storage interop for Blazor - `RpgRoller/wwwroot/styles.css`: responsive UX styling and theme tokens diff --git a/RpgRoller/Components/Pages/Home.Api.cs b/RpgRoller/Components/Pages/Home.Api.cs index 93a7bea..e64d5bc 100644 --- a/RpgRoller/Components/Pages/Home.Api.cs +++ b/RpgRoller/Components/Pages/Home.Api.cs @@ -1,20 +1 @@ -using Microsoft.AspNetCore.Components; -using RpgRoller.Components; - -namespace RpgRoller.Components.Pages; - -public partial class Home -{ - [Inject] - private RpgRollerApiClient ApiClient { get; set; } = default!; - - private Task RequestAsync(string method, string path, object? payload = null) - { - return ApiClient.RequestAsync(method, path, payload); - } - - private Task RequestWithoutPayloadAsync(string method, string path) - { - return ApiClient.RequestWithoutPayloadAsync(method, path); - } -} +// Moved into Home.razor.cs and Workspace.razor during cleanup. diff --git a/RpgRoller/Components/Pages/Home.Auth.cs b/RpgRoller/Components/Pages/Home.Auth.cs index f9b4832..e64d5bc 100644 --- a/RpgRoller/Components/Pages/Home.Auth.cs +++ b/RpgRoller/Components/Pages/Home.Auth.cs @@ -1,44 +1 @@ -using RpgRoller.Components; - -namespace RpgRoller.Components.Pages; - -public partial class Home -{ - private async Task OnLoggedInAsync() - { - try - { - await ReloadAuthenticatedSessionAsync(null); - SetStatus("Logged in.", false); - } - catch (ApiRequestException ex) - { - SetStatus(ex.Message, true); - } - } - - private async Task LogoutAsync() - { - if (IsMutating) - { - return; - } - - IsMutating = true; - try - { - await RequestWithoutPayloadAsync("POST", "/api/auth/logout"); - } - catch (ApiRequestException) - { - } - finally - { - IsMutating = false; - } - - ClearAuthenticatedState(); - await StopStateEventsAsync(); - SetStatus("Logged out.", false); - } -} +// Moved into Home.razor.cs and Workspace.razor during cleanup. diff --git a/RpgRoller/Components/Pages/Home.Campaign.cs b/RpgRoller/Components/Pages/Home.Campaign.cs index 11e9ba5..e64d5bc 100644 --- a/RpgRoller/Components/Pages/Home.Campaign.cs +++ b/RpgRoller/Components/Pages/Home.Campaign.cs @@ -1,60 +1 @@ -using Microsoft.AspNetCore.Components; -using Microsoft.JSInterop; - -namespace RpgRoller.Components.Pages; - -public partial class Home -{ - private async Task SwitchScreenAsync(string screen) - { - CurrentScreen = string.Equals(screen, "management", StringComparison.OrdinalIgnoreCase) ? "management" : "play"; - await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", ScreenSessionKey, CurrentScreen); - } - - private Task SwitchToPlayAsync() - { - return SwitchScreenAsync("play"); - } - - private Task SwitchToManagementAsync() - { - return SwitchScreenAsync("management"); - } - - private async Task SetMobilePanelAsync(string panel) - { - MobilePanel = string.Equals(panel, "log", StringComparison.OrdinalIgnoreCase) ? "log" : "character"; - await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", MobilePanelSessionKey, MobilePanel); - } - - private Task SetMobilePanelCharacterAsync() - { - return SetMobilePanelAsync("character"); - } - - private Task SetMobilePanelLogAsync() - { - return SetMobilePanelAsync("log"); - } - - private async Task OnCampaignSelectionChangedAsync(ChangeEventArgs args) - { - if (!Guid.TryParse(args.Value?.ToString(), out var campaignId)) - { - return; - } - - SelectedCampaignId = campaignId; - await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, campaignId.ToString()); - await RefreshCampaignScopeAsync(); - await SyncStateEventsAsync(); - } - - private async Task OnCampaignCreatedAsync(Guid campaignId) - { - await ReloadCampaignsAsync(campaignId); - await RefreshCampaignScopeAsync(); - await SyncStateEventsAsync(); - SetStatus("Campaign created.", false); - } -} +// Moved into Home.razor.cs and Workspace.razor during cleanup. diff --git a/RpgRoller/Components/Pages/Home.Character.cs b/RpgRoller/Components/Pages/Home.Character.cs index 312edb7..e64d5bc 100644 --- a/RpgRoller/Components/Pages/Home.Character.cs +++ b/RpgRoller/Components/Pages/Home.Character.cs @@ -1,96 +1 @@ -using RpgRoller.Contracts; - -namespace RpgRoller.Components.Pages; - -public partial class Home -{ - private void OpenCreateCharacterModal() - { - CreateCharacterInitialModel = new CharacterFormModel - { - Name = string.Empty, - CampaignId = SelectedCampaignId?.ToString() ?? string.Empty - }; - CreateCharacterFormVersion++; - ShowCreateCharacterModal = true; - } - - private void OpenEditCharacterModal(CharacterSummary character) - { - EditingCharacterId = character.Id; - - EditCharacterInitialModel = new CharacterFormModel - { - Name = character.Name, - CampaignId = character.CampaignId.ToString() - }; - EditCharacterFormVersion++; - ShowEditCharacterModal = true; - } - - private void CloseCharacterModals() - { - ShowCreateCharacterModal = false; - ShowEditCharacterModal = false; - EditingCharacterId = null; - } - - private async Task OnCharacterCreatedAsync(Guid campaignId) - { - CloseCharacterModals(); - await ReloadCampaignsAsync(campaignId); - await RefreshCampaignScopeAsync(); - await SyncStateEventsAsync(); - SetStatus("Character created.", false); - } - - private async Task OnCharacterUpdatedAsync(Guid campaignId) - { - CloseCharacterModals(); - await ReloadCampaignsAsync(campaignId); - await RefreshCampaignScopeAsync(); - await SyncStateEventsAsync(); - SetStatus("Character updated.", false); - } - - private async Task SelectCharacterAsync(Guid characterId) - { - SelectedCharacterId = characterId; - SyncSelectedSkill(); - await EnsureSelectedCharacterActiveAsync(); - } - - private bool CanEditCharacter(CharacterSummary character) - { - return User is not null && (character.OwnerUserId == User.Id || IsCurrentUserGm); - } - - private static bool CanActivateCharacter(CharacterSummary character, UserSummary? user) - { - return user is not null && character.OwnerUserId == user.Id; - } - - private async Task EnsureSelectedCharacterActiveAsync() - { - if (!SelectedCharacterId.HasValue || SelectedCampaign is null) - { - return; - } - - var character = SelectedCampaign.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId.Value); - if (character is null || !CanActivateCharacter(character, User) || ActiveCharacterId == character.Id) - { - return; - } - - try - { - await RequestWithoutPayloadAsync("POST", $"/api/characters/{character.Id}/activate"); - ActiveCharacterId = character.Id; - } - catch (ApiRequestException ex) - { - SetStatus(ex.Message, true); - } - } -} +// Moved into Home.razor.cs and Workspace.razor during cleanup. diff --git a/RpgRoller/Components/Pages/Home.Lifecycle.cs b/RpgRoller/Components/Pages/Home.Lifecycle.cs index 8c0c933..e64d5bc 100644 --- a/RpgRoller/Components/Pages/Home.Lifecycle.cs +++ b/RpgRoller/Components/Pages/Home.Lifecycle.cs @@ -1,202 +1 @@ -using RpgRoller.Contracts; -using Microsoft.JSInterop; - -namespace RpgRoller.Components.Pages; - -public partial class Home -{ - protected override async Task OnAfterRenderAsync(bool firstRender) - { - HasInteractiveRenderStarted = true; - if (!firstRender) - { - return; - } - - await InitializeAsync(); - await InvokeAsync(StateHasChanged); - } - - private async Task InitializeAsync() - { - var storedScreen = await JS.InvokeAsync("rpgRollerApi.getSessionValue", ScreenSessionKey); - if (string.Equals(storedScreen, "management", StringComparison.OrdinalIgnoreCase)) - { - CurrentScreen = "management"; - } - - var storedPanel = await JS.InvokeAsync("rpgRollerApi.getSessionValue", MobilePanelSessionKey); - if (string.Equals(storedPanel, "log", StringComparison.OrdinalIgnoreCase)) - { - MobilePanel = "log"; - } - - Guid? preferredCampaignId = null; - var storedCampaignId = await JS.InvokeAsync("rpgRollerApi.getSessionValue", CampaignSessionKey); - if (Guid.TryParse(storedCampaignId, out var parsedCampaignId)) - { - preferredCampaignId = parsedCampaignId; - } - - await CheckHealthAsync(); - await LoadRulesetsAsync(); - await ReloadAuthenticatedSessionAsync(preferredCampaignId); - IsInitialized = true; - } - - private async Task RetryAfterHealthIssueAsync() - { - await CheckHealthAsync(); - if (!HasHealthIssue && User is not null) - { - await ReloadAuthenticatedSessionAsync(SelectedCampaignId); - } - } - - private async Task CheckHealthAsync() - { - try - { - var health = await RequestAsync("GET", "/api/health"); - if (!string.Equals(health.Status, "ok", StringComparison.OrdinalIgnoreCase)) - { - HasHealthIssue = true; - HealthIssueMessage = "Health endpoint returned an unhealthy response."; - return; - } - - HasHealthIssue = false; - HealthIssueMessage = string.Empty; - } - catch (ApiRequestException) - { - HasHealthIssue = true; - HealthIssueMessage = "Unable to reach API. Retry to continue."; - } - } - - private async Task LoadRulesetsAsync() - { - try - { - Rulesets = (await RequestAsync>("GET", "/api/rulesets")).ToList(); - } - catch (ApiRequestException ex) - { - SetStatus(ex.Message, true); - } - } - - private async Task ReloadAuthenticatedSessionAsync(Guid? preferredCampaignId) - { - var me = await TryGetMeAsync(); - if (me is null) - { - ClearAuthenticatedState(); - await StopStateEventsAsync(); - return; - } - - User = me.User; - ActiveCharacterId = me.ActiveCharacterId; - - await ReloadCampaignsAsync(preferredCampaignId ?? me.CurrentCampaignId); - await RefreshCampaignScopeAsync(); - await SyncStateEventsAsync(); - } - - private async Task TryGetMeAsync() - { - try - { - return await RequestAsync("GET", "/api/me"); - } - catch (ApiRequestException ex) when (ex.StatusCode == 401) - { - return null; - } - } - - private async Task ReloadCampaignsAsync(Guid? preferredCampaignId) - { - var campaigns = await RequestAsync>("GET", "/api/campaigns"); - Campaigns = campaigns.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase).ToList(); - - if (Campaigns.Count == 0) - { - SelectedCampaignId = null; - await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, null); - return; - } - - var campaignIds = Campaigns.Select(c => c.Id).ToHashSet(); - if (preferredCampaignId.HasValue && campaignIds.Contains(preferredCampaignId.Value)) - { - SelectedCampaignId = preferredCampaignId.Value; - } - else if (!SelectedCampaignId.HasValue || !campaignIds.Contains(SelectedCampaignId.Value)) - { - SelectedCampaignId = Campaigns[0].Id; - } - - await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, SelectedCampaignId?.ToString()); - } - - private async Task RefreshCampaignScopeAsync() - { - if (!SelectedCampaignId.HasValue) - { - SelectedCampaign = null; - CampaignLog = []; - SelectedCharacterId = null; - SelectedSkillId = null; - ConnectionState = "offline"; - return; - } - - IsCampaignDataLoading = true; - try - { - var campaignId = SelectedCampaignId.Value; - SelectedCampaign = await RequestAsync("GET", $"/api/campaigns/{campaignId}"); - CampaignLog = (await RequestAsync>("GET", $"/api/campaigns/{campaignId}/log")).ToList(); - SyncSelectedCharacter(); - SyncSelectedSkill(); - await EnsureSelectedCharacterActiveAsync(); - } - catch (ApiRequestException ex) when (ex.StatusCode == 401) - { - ClearAuthenticatedState(); - await StopStateEventsAsync(); - SetStatus("Session expired. Please log in again.", true); - } - catch (ApiRequestException ex) - { - SetStatus(ex.Message, true); - } - finally - { - IsCampaignDataLoading = false; - } - } - - private async Task ManualRefreshAsync() - { - if (IsMutating) - { - return; - } - - IsMutating = true; - try - { - await CheckHealthAsync(); - await ReloadAuthenticatedSessionAsync(SelectedCampaignId); - SetStatus("Campaign data refreshed.", false); - } - finally - { - IsMutating = false; - } - } -} +// Moved into Home.razor.cs and Workspace.razor during cleanup. diff --git a/RpgRoller/Components/Pages/Home.Presentation.cs b/RpgRoller/Components/Pages/Home.Presentation.cs index 6045895..e64d5bc 100644 --- a/RpgRoller/Components/Pages/Home.Presentation.cs +++ b/RpgRoller/Components/Pages/Home.Presentation.cs @@ -1,173 +1 @@ -using RpgRoller.Contracts; - -namespace RpgRoller.Components.Pages; - -public partial class Home -{ - private void SyncSelectedCharacter() - { - if (SelectedCampaign is null || SelectedCampaign.Characters.Count == 0) - { - SelectedCharacterId = null; - return; - } - - var candidateIds = SelectedCampaign.Characters.Select(c => c.Id).ToHashSet(); - if (SelectedCharacterId.HasValue && candidateIds.Contains(SelectedCharacterId.Value)) - { - return; - } - - if (ActiveCharacterId.HasValue && candidateIds.Contains(ActiveCharacterId.Value)) - { - SelectedCharacterId = ActiveCharacterId; - return; - } - - SelectedCharacterId = SelectedCampaign.Characters[0].Id; - } - - private void SyncSelectedSkill() - { - var skills = SelectedCharacterSkills; - if (skills.Count == 0) - { - SelectedSkillId = null; - return; - } - - if (SelectedSkillId.HasValue && skills.Any(skill => skill.Id == SelectedSkillId.Value)) - { - return; - } - - SelectedSkillId = skills[0].Id; - } - - private string OwnerLabel(Guid ownerUserId) - { - if (User is not null && ownerUserId == User.Id) - { - return "You"; - } - - if (SelectedCampaign is not null && ownerUserId == SelectedCampaign.Gm.Id) - { - return $"{SelectedCampaign.Gm.DisplayName} (GM)"; - } - - return ownerUserId.ToString("N")[..8]; - } - - private string CharacterLabel(Guid characterId) - { - return SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == characterId)?.Name ?? "Hidden character"; - } - - private string SkillLabel(Guid skillId) - { - return SelectedCampaign?.Skills.FirstOrDefault(s => s.Id == skillId)?.Name ?? "Hidden skill"; - } - - private string SkillDefinitionLabel(SkillSummary skill) - { - if (!string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase)) - { - return skill.DiceRollDefinition; - } - - var fumbleLabel = skill.AllowFumble ? "fumble on" : "fumble off"; - return $"{skill.DiceRollDefinition} | wild {skill.WildDice}, {fumbleLabel}"; - } - - private string RollerLabel(CampaignLogEntry entry) - { - if (User is not null && entry.RollerUserId == User.Id) - { - return "You"; - } - - if (SelectedCampaign is not null && entry.RollerUserId == SelectedCampaign.Gm.Id) - { - return "GM"; - } - - return "Participant"; - } - - private string VisibilityLabel(CampaignLogEntry entry) - { - if (!string.Equals(entry.Visibility, "private", StringComparison.OrdinalIgnoreCase)) - { - return "Public"; - } - - if (User is not null && entry.RollerUserId == User.Id) - { - return "Private (you)"; - } - - return IsCurrentUserGm ? "Private (GM view)" : "Private"; - } - - private string VisibilityBadgeCssClass(CampaignLogEntry entry) - { - if (!string.Equals(entry.Visibility, "private", StringComparison.OrdinalIgnoreCase)) - { - return "public"; - } - - if (User is not null && entry.RollerUserId == User.Id) - { - return "private-self"; - } - - return IsCurrentUserGm ? "private-gm" : "private-generic"; - } - - private string LogEntryCssClass(CampaignLogEntry entry) - { - if (!string.Equals(entry.Visibility, "private", StringComparison.OrdinalIgnoreCase)) - { - return "public"; - } - - if (User is not null && entry.RollerUserId == User.Id) - { - return "private-self"; - } - - return IsCurrentUserGm ? "private-gm" : "private-generic"; - } - - private void ClearAuthenticatedState() - { - User = null; - ActiveCharacterId = null; - SelectedCampaignId = null; - SelectedCampaign = null; - Campaigns = []; - CampaignLog = []; - SelectedCharacterId = null; - SelectedSkillId = null; - LastRoll = null; - ShowCreateCharacterModal = false; - ShowEditCharacterModal = false; - CreateCharacterInitialModel = new(); - EditCharacterInitialModel = new(); - CreateCharacterFormVersion = 0; - EditCharacterFormVersion = 0; - } - - private void SetStatus(string message, bool isError) - { - StatusMessage = message; - StatusIsError = isError; - Announce(message); - } - - private void Announce(string message) - { - LiveAnnouncement = message; - } -} +// Moved into Home.razor.cs and Workspace.razor during cleanup. diff --git a/RpgRoller/Components/Pages/Home.Realtime.cs b/RpgRoller/Components/Pages/Home.Realtime.cs index 59e8f89..e64d5bc 100644 --- a/RpgRoller/Components/Pages/Home.Realtime.cs +++ b/RpgRoller/Components/Pages/Home.Realtime.cs @@ -1,93 +1 @@ -using Microsoft.JSInterop; - -namespace RpgRoller.Components.Pages; - -public partial class Home -{ - [JSInvokable] - public async Task OnStateEventReceived(long _) - { - if (StateRefreshInProgress) - { - return; - } - - StateRefreshInProgress = true; - try - { - await RefreshCampaignScopeAsync(); - } - finally - { - StateRefreshInProgress = false; - await InvokeAsync(StateHasChanged); - } - } - - [JSInvokable] - public Task OnConnectionStateChanged(string state) - { - ConnectionState = state switch - { - "connected" => "connected", - "reconnecting" => "reconnecting", - _ => "offline" - }; - - if (ConnectionState == "reconnecting") - { - Announce("Reconnecting to live updates."); - } - - if (ConnectionState == "offline") - { - Announce("Live updates offline. Use manual refresh."); - } - - return InvokeAsync(StateHasChanged); - } - - private async Task SyncStateEventsAsync() - { - if (User is null || !SelectedCampaignId.HasValue) - { - await StopStateEventsAsync(); - ConnectionState = "offline"; - return; - } - - DotNetRef ??= DotNetObjectReference.Create(this); - await JS.InvokeVoidAsync("rpgRollerApi.startStateEvents", SelectedCampaignId.Value.ToString(), DotNetRef); - ConnectionState = "reconnecting"; - } - - private async Task StopStateEventsAsync() - { - if (!HasInteractiveRenderStarted) - { - return; - } - - try - { - await JS.InvokeVoidAsync("rpgRollerApi.stopStateEvents"); - } - catch (JSDisconnectedException) - { - } - catch (InvalidOperationException ex) when (IsStaticRenderInteropException(ex)) - { - } - } - - public async ValueTask DisposeAsync() - { - await StopStateEventsAsync(); - DotNetRef?.Dispose(); - } - - private static bool IsStaticRenderInteropException(InvalidOperationException exception) - { - return exception.Message.Contains("statically rendered", StringComparison.OrdinalIgnoreCase); - } -} +// Moved into Home.razor.cs and Workspace.razor during cleanup. diff --git a/RpgRoller/Components/Pages/Home.Skill.cs b/RpgRoller/Components/Pages/Home.Skill.cs index ad27c6e..e64d5bc 100644 --- a/RpgRoller/Components/Pages/Home.Skill.cs +++ b/RpgRoller/Components/Pages/Home.Skill.cs @@ -1,76 +1 @@ -using RpgRoller.Contracts; - -namespace RpgRoller.Components.Pages; - -public partial class Home -{ - private async Task OnSkillCreatedAsync(Guid _) - { - await RefreshCampaignScopeAsync(); - SetStatus("Skill created.", false); - } - - private async Task OnSkillUpdatedAsync(Guid skillId) - { - SelectedSkillId = skillId; - await RefreshCampaignScopeAsync(); - SetStatus("Skill updated.", false); - } - - private async Task RollSelectedSkillAsync() - { - if (SelectedSkill is null) - { - SetStatus("Select a skill to roll.", true); - return; - } - - IsMutating = true; - try - { - LastRoll = await RequestAsync( - "POST", - $"/api/skills/{SelectedSkill.Id}/roll", - new RollSkillRequest(RollVisibility)); - - await RefreshCampaignScopeAsync(); - SetStatus("Roll recorded.", false); - Announce("Roll result updated."); - } - catch (ApiRequestException ex) - { - SetStatus(ex.Message, true); - } - finally - { - IsMutating = false; - } - } - - private Task OnRollVisibilityChanged(string visibility) - { - RollVisibility = string.Equals(visibility, "private", StringComparison.OrdinalIgnoreCase) ? "private" : "public"; - return Task.CompletedTask; - } - - private void SelectSkill(Guid skillId) - { - SelectedSkillId = skillId; - } - - private bool CanEditSkill(SkillSummary skill) - { - if (SelectedCampaign is null) - { - return false; - } - - var character = SelectedCampaign.Characters.FirstOrDefault(c => c.Id == skill.CharacterId); - return character is not null && CanEditCharacter(character); - } - - private bool CanRollSkill(SkillSummary skill) - { - return CanEditSkill(skill); - } -} +// Moved into Home.razor.cs and Workspace.razor during cleanup. diff --git a/RpgRoller/Components/Pages/Home.State.cs b/RpgRoller/Components/Pages/Home.State.cs index e38b580..e64d5bc 100644 --- a/RpgRoller/Components/Pages/Home.State.cs +++ b/RpgRoller/Components/Pages/Home.State.cs @@ -1,107 +1 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using Microsoft.AspNetCore.Components; -using Microsoft.JSInterop; -using RpgRoller.Contracts; - -namespace RpgRoller.Components.Pages; - -[ExcludeFromCodeCoverage] -public partial class Home -{ - private const string ScreenSessionKey = "screen"; - private const string CampaignSessionKey = "campaign"; - private const string MobilePanelSessionKey = "play-panel"; - - private UserSummary? User { get; set; } - private Guid? ActiveCharacterId { get; set; } - private Guid? SelectedCampaignId { get; set; } - private CampaignDetails? SelectedCampaign { get; set; } - private List Campaigns { get; set; } = []; - private List CampaignLog { get; set; } = []; - private List Rulesets { get; set; } = []; - private Guid? SelectedCharacterId { get; set; } - private Guid? SelectedSkillId { get; set; } - private RollResult? LastRoll { get; set; } - private string RollVisibility { get; set; } = "public"; - - private bool IsInitialized { get; set; } - private bool IsMutating { get; set; } - private bool IsCampaignDataLoading { get; set; } - private bool HasHealthIssue { get; set; } - private string HealthIssueMessage { get; set; } = "Retry to restore the API connection."; - private string? StatusMessage { get; set; } - private bool StatusIsError { get; set; } - private string CurrentScreen { get; set; } = "play"; - private string MobilePanel { get; set; } = "character"; - private string ConnectionState { get; set; } = "offline"; - private string LiveAnnouncement { get; set; } = string.Empty; - - private bool ShowCreateCharacterModal { get; set; } - private bool ShowEditCharacterModal { get; set; } - private Guid? EditingCharacterId { get; set; } - private CharacterFormModel CreateCharacterInitialModel { get; set; } = new(); - private CharacterFormModel EditCharacterInitialModel { get; set; } = new(); - private int CreateCharacterFormVersion { get; set; } - private int EditCharacterFormVersion { get; set; } - private bool StateRefreshInProgress { get; set; } - private bool HasInteractiveRenderStarted { get; set; } - private DotNetObjectReference? DotNetRef { get; set; } - - [Inject] - private IJSRuntime JS { get; set; } = default!; - - private string? SelectedCampaignName => SelectedCampaign?.Name; - - private CharacterSummary? SelectedCharacter => - SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId); - - private SkillSummary? SelectedSkill => - SelectedCampaign?.Skills.FirstOrDefault(s => s.Id == SelectedSkillId); - - private string? ActiveCharacterName => - SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId)?.Name; - - private bool IsCurrentUserGm => - SelectedCampaign is not null && User is not null && SelectedCampaign.Gm.Id == User.Id; - - private bool IsSelectedCampaignD6 => - string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase); - - private List SelectedCharacterSkills => - SelectedCampaign is null || !SelectedCharacterId.HasValue - ? [] - : SelectedCampaign.Skills - .Where(skill => skill.CharacterId == SelectedCharacterId.Value) - .OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase) - .ToList(); - - private HomeViewMode CurrentView => - !IsInitialized - ? HomeViewMode.Loading - : User is null - ? HomeViewMode.Anonymous - : HomeViewMode.Workspace; - - private bool IsPlayScreen => string.Equals(CurrentScreen, "play", StringComparison.OrdinalIgnoreCase); - private bool IsManagementScreen => !IsPlayScreen; - - private string ConnectionStateLabel => ConnectionState switch - { - "connected" => "Connected", - "reconnecting" => "Reconnecting", - _ => "Offline fallback" - }; - - private string ConnectionStateCssClass => ConnectionState switch - { - "connected" => "ok", - "reconnecting" => "warn", - _ => "offline" - }; - - private string AppCssClass => - User is not null && IsPlayScreen ? "rr-app app-play" : "rr-app"; -} +// Moved into Home.razor.cs and Workspace.razor during cleanup. diff --git a/RpgRoller/Components/Pages/Home.Validation.cs b/RpgRoller/Components/Pages/Home.Validation.cs index c1b7b09..e64d5bc 100644 --- a/RpgRoller/Components/Pages/Home.Validation.cs +++ b/RpgRoller/Components/Pages/Home.Validation.cs @@ -1,5 +1 @@ -namespace RpgRoller.Components.Pages; - -public partial class Home -{ -} +// Moved into Home.razor.cs and Workspace.razor during cleanup. diff --git a/RpgRoller/Components/Pages/Home.razor b/RpgRoller/Components/Pages/Home.razor index 9bfbf34..4dc4fe6 100644 --- a/RpgRoller/Components/Pages/Home.razor +++ b/RpgRoller/Components/Pages/Home.razor @@ -1,156 +1,27 @@ @page "/" -@implements IAsyncDisposable @using RpgRoller.Components.Pages.HomeControls -
-

@LiveAnnouncement

- - @if (HasHealthIssue) - { - - } - - @switch (CurrentView) - { - case HomeViewMode.Loading: +@switch (CurrentView) +{ + case HomeViewMode.Loading: +

RpgRoller

Connecting...

- break; +
+ break; - case HomeViewMode.Anonymous: + case HomeViewMode.Anonymous: +
- break; +
+ break; - case HomeViewMode.Workspace: -
-
-
-

RpgRoller

-

Tabletop utility cockpit

-
-
-

@User!.DisplayName (@User.Username)

-

Campaign: @(SelectedCampaignName ?? "No campaign selected")

-

Active: @(ActiveCharacterName ?? "None selected")

-
-
-

@ConnectionStateLabel

-
- - -
-
- - -
-
-
- - @if (!string.IsNullOrWhiteSpace(StatusMessage)) - { -

@StatusMessage

- } - - @if (IsPlayScreen) - { -
- - - -
- - } - - @if (IsManagementScreen) - { - - } -
- break; - } -
- - - - + case HomeViewMode.Workspace: + + break; +} diff --git a/RpgRoller/Components/Pages/Home.razor.cs b/RpgRoller/Components/Pages/Home.razor.cs index c1b7b09..df13749 100644 --- a/RpgRoller/Components/Pages/Home.razor.cs +++ b/RpgRoller/Components/Pages/Home.razor.cs @@ -1,5 +1,81 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Components; +using RpgRoller.Components; +using RpgRoller.Contracts; + namespace RpgRoller.Components.Pages; +[ExcludeFromCodeCoverage] public partial class Home { + private HomeViewMode CurrentView { get; set; } = HomeViewMode.Loading; + private string? StatusMessage { get; set; } + private bool StatusIsError { get; set; } + private bool HasInitialized { get; set; } + + [Inject] + private RpgRollerApiClient ApiClient { get; set; } = default!; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender || HasInitialized) + { + return; + } + + HasInitialized = true; + await InitializeAsync(); + await InvokeAsync(StateHasChanged); + } + + private async Task InitializeAsync() + { + try + { + _ = await ApiClient.RequestAsync("GET", "/api/me"); + CurrentView = HomeViewMode.Workspace; + ClearStatus(); + } + catch (ApiRequestException ex) when (ex.StatusCode == 401) + { + CurrentView = HomeViewMode.Anonymous; + ClearStatus(); + } + catch (ApiRequestException ex) + { + CurrentView = HomeViewMode.Anonymous; + SetStatus(ex.Message, true); + } + } + + private void OnLoggedInAsync() + { + CurrentView = HomeViewMode.Workspace; + ClearStatus(); + } + + private void OnLoggedOutAsync(string? message) + { + CurrentView = HomeViewMode.Anonymous; + if (string.IsNullOrWhiteSpace(message)) + { + ClearStatus(); + return; + } + + var isError = message.Contains("expired", StringComparison.OrdinalIgnoreCase); + SetStatus(message, isError); + } + + private void SetStatus(string message, bool isError) + { + StatusMessage = message; + StatusIsError = isError; + } + + private void ClearStatus() + { + StatusMessage = null; + StatusIsError = false; + } } diff --git a/RpgRoller/Components/Pages/Workspace.razor b/RpgRoller/Components/Pages/Workspace.razor new file mode 100644 index 0000000..5fdf6d2 --- /dev/null +++ b/RpgRoller/Components/Pages/Workspace.razor @@ -0,0 +1,919 @@ +@using System.Diagnostics.CodeAnalysis +@using Microsoft.JSInterop +@using RpgRoller.Components +@using RpgRoller.Components.Pages.HomeControls +@using RpgRoller.Contracts +@attribute [ExcludeFromCodeCoverage] +@implements IAsyncDisposable +@inject IJSRuntime JS +@inject RpgRollerApiClient ApiClient + +
+

@LiveAnnouncement

+ + @if (HasHealthIssue) + { + + } + +
+
+
+

RpgRoller

+

Tabletop utility cockpit

+
+
+

@User!.DisplayName (@User.Username)

+

Campaign: @(SelectedCampaignName ?? "No campaign selected")

+

Active: @(ActiveCharacterName ?? "None selected")

+
+
+

@ConnectionStateLabel

+
+ + +
+
+ + +
+
+
+ + @if (!string.IsNullOrWhiteSpace(StatusMessage)) + { +

@StatusMessage

+ } + + @if (IsPlayScreen) + { +
+ + + +
+ + } + + @if (IsManagementScreen) + { + + } +
+
+ + + + + +@code { + private const string ScreenSessionKey = "screen"; + private const string CampaignSessionKey = "campaign"; + private const string MobilePanelSessionKey = "play-panel"; + + private UserSummary? User { get; set; } + private Guid? ActiveCharacterId { get; set; } + private Guid? SelectedCampaignId { get; set; } + private CampaignDetails? SelectedCampaign { get; set; } + private List Campaigns { get; set; } = []; + private List CampaignLog { get; set; } = []; + private List Rulesets { get; set; } = []; + private Guid? SelectedCharacterId { get; set; } + private Guid? SelectedSkillId { get; set; } + private RollResult? LastRoll { get; set; } + private string RollVisibility { get; set; } = "public"; + + private bool IsMutating { get; set; } + private bool IsCampaignDataLoading { get; set; } + private bool HasHealthIssue { get; set; } + private string HealthIssueMessage { get; set; } = "Retry to restore the API connection."; + private string? StatusMessage { get; set; } + private bool StatusIsError { get; set; } + private string CurrentScreen { get; set; } = "play"; + private string MobilePanel { get; set; } = "character"; + private string ConnectionState { get; set; } = "offline"; + private string LiveAnnouncement { get; set; } = string.Empty; + + private bool ShowCreateCharacterModal { get; set; } + private bool ShowEditCharacterModal { get; set; } + private Guid? EditingCharacterId { get; set; } + private CharacterFormModel CreateCharacterInitialModel { get; set; } = new(); + private CharacterFormModel EditCharacterInitialModel { get; set; } = new(); + private int CreateCharacterFormVersion { get; set; } + private int EditCharacterFormVersion { get; set; } + private bool StateRefreshInProgress { get; set; } + private bool HasInteractiveRenderStarted { get; set; } + private DotNetObjectReference? DotNetRef { get; set; } + + [Parameter] + public EventCallback LoggedOut { get; set; } + + private string? SelectedCampaignName => SelectedCampaign?.Name; + + private CharacterSummary? SelectedCharacter => + SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId); + + private SkillSummary? SelectedSkill => + SelectedCampaign?.Skills.FirstOrDefault(s => s.Id == SelectedSkillId); + + private string? ActiveCharacterName => + SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId)?.Name; + + private bool IsCurrentUserGm => + SelectedCampaign is not null && User is not null && SelectedCampaign.Gm.Id == User.Id; + + private bool IsSelectedCampaignD6 => + string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase); + + private List SelectedCharacterSkills => + SelectedCampaign is null || !SelectedCharacterId.HasValue + ? [] + : SelectedCampaign.Skills + .Where(skill => skill.CharacterId == SelectedCharacterId.Value) + .OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + private bool IsPlayScreen => string.Equals(CurrentScreen, "play", StringComparison.OrdinalIgnoreCase); + private bool IsManagementScreen => !IsPlayScreen; + + private string ConnectionStateLabel => ConnectionState switch + { + "connected" => "Connected", + "reconnecting" => "Reconnecting", + _ => "Offline fallback" + }; + + private string ConnectionStateCssClass => ConnectionState switch + { + "connected" => "ok", + "reconnecting" => "warn", + _ => "offline" + }; + + private string AppCssClass => IsPlayScreen ? "rr-app app-play" : "rr-app"; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + HasInteractiveRenderStarted = true; + if (!firstRender) + { + return; + } + + await InitializeAsync(); + await InvokeAsync(StateHasChanged); + } + + private async Task InitializeAsync() + { + var storedScreen = await JS.InvokeAsync("rpgRollerApi.getSessionValue", ScreenSessionKey); + if (string.Equals(storedScreen, "management", StringComparison.OrdinalIgnoreCase)) + { + CurrentScreen = "management"; + } + + var storedPanel = await JS.InvokeAsync("rpgRollerApi.getSessionValue", MobilePanelSessionKey); + if (string.Equals(storedPanel, "log", StringComparison.OrdinalIgnoreCase)) + { + MobilePanel = "log"; + } + + 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."); + } + } + + private async Task RetryAfterHealthIssueAsync() + { + 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() + { + try + { + var health = await ApiClient.RequestAsync("GET", "/api/health"); + if (!string.Equals(health.Status, "ok", StringComparison.OrdinalIgnoreCase)) + { + HasHealthIssue = true; + HealthIssueMessage = "Health endpoint returned an unhealthy response."; + return; + } + + HasHealthIssue = false; + HealthIssueMessage = string.Empty; + } + catch (ApiRequestException) + { + HasHealthIssue = true; + HealthIssueMessage = "Unable to reach API. Retry to continue."; + } + } + + private async Task LoadRulesetsAsync() + { + try + { + Rulesets = (await ApiClient.RequestAsync>("GET", "/api/rulesets")).ToList(); + } + catch (ApiRequestException ex) + { + 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 ReloadCampaignsAsync(preferredCampaignId ?? me.CurrentCampaignId); + await RefreshCampaignScopeAsync(); + await SyncStateEventsAsync(); + return true; + } + + private async Task TryGetMeAsync() + { + try + { + return await ApiClient.RequestAsync("GET", "/api/me"); + } + catch (ApiRequestException ex) when (ex.StatusCode == 401) + { + return null; + } + } + + private async Task ReloadCampaignsAsync(Guid? preferredCampaignId) + { + var campaigns = await ApiClient.RequestAsync>("GET", "/api/campaigns"); + Campaigns = campaigns.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase).ToList(); + + if (Campaigns.Count == 0) + { + SelectedCampaignId = null; + await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, null); + return; + } + + var campaignIds = Campaigns.Select(c => c.Id).ToHashSet(); + if (preferredCampaignId.HasValue && campaignIds.Contains(preferredCampaignId.Value)) + { + SelectedCampaignId = preferredCampaignId.Value; + } + else if (!SelectedCampaignId.HasValue || !campaignIds.Contains(SelectedCampaignId.Value)) + { + SelectedCampaignId = Campaigns[0].Id; + } + + await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, SelectedCampaignId?.ToString()); + } + + private async Task RefreshCampaignScopeAsync() + { + if (!SelectedCampaignId.HasValue) + { + SelectedCampaign = null; + CampaignLog = []; + SelectedCharacterId = null; + SelectedSkillId = null; + ConnectionState = "offline"; + return; + } + + IsCampaignDataLoading = true; + try + { + var campaignId = SelectedCampaignId.Value; + SelectedCampaign = await ApiClient.RequestAsync("GET", $"/api/campaigns/{campaignId}"); + CampaignLog = (await ApiClient.RequestAsync>("GET", $"/api/campaigns/{campaignId}/log")).ToList(); + SyncSelectedCharacter(); + SyncSelectedSkill(); + await EnsureSelectedCharacterActiveAsync(); + } + catch (ApiRequestException ex) when (ex.StatusCode == 401) + { + ClearAuthenticatedState(); + await StopStateEventsAsync(); + await LoggedOut.InvokeAsync("Session expired. Please log in again."); + } + catch (ApiRequestException ex) + { + SetStatus(ex.Message, true); + } + finally + { + IsCampaignDataLoading = false; + } + } + + private async Task ManualRefreshAsync() + { + if (IsMutating) + { + return; + } + + IsMutating = true; + try + { + await CheckHealthAsync(); + var reloaded = await ReloadAuthenticatedSessionAsync(SelectedCampaignId); + if (!reloaded) + { + await LoggedOut.InvokeAsync("Session expired. Please log in again."); + return; + } + + SetStatus("Campaign data refreshed.", false); + } + finally + { + IsMutating = false; + } + } + + private async Task LogoutAsync() + { + if (IsMutating) + { + return; + } + + 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) + { + CurrentScreen = string.Equals(screen, "management", StringComparison.OrdinalIgnoreCase) ? "management" : "play"; + await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", ScreenSessionKey, CurrentScreen); + } + + private Task SwitchToPlayAsync() => SwitchScreenAsync("play"); + private Task SwitchToManagementAsync() => SwitchScreenAsync("management"); + + private async Task SetMobilePanelAsync(string panel) + { + MobilePanel = string.Equals(panel, "log", StringComparison.OrdinalIgnoreCase) ? "log" : "character"; + await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", MobilePanelSessionKey, MobilePanel); + } + + private Task SetMobilePanelCharacterAsync() => SetMobilePanelAsync("character"); + private Task SetMobilePanelLogAsync() => SetMobilePanelAsync("log"); + + private async Task OnCampaignSelectionChangedAsync(ChangeEventArgs args) + { + if (!Guid.TryParse(args.Value?.ToString(), out var campaignId)) + { + return; + } + + SelectedCampaignId = campaignId; + await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, campaignId.ToString()); + await RefreshCampaignScopeAsync(); + await SyncStateEventsAsync(); + } + + private async Task OnCampaignCreatedAsync(Guid campaignId) + { + await ReloadCampaignsAsync(campaignId); + await RefreshCampaignScopeAsync(); + await SyncStateEventsAsync(); + SetStatus("Campaign created.", false); + } + + private void OpenCreateCharacterModal() + { + CreateCharacterInitialModel = new CharacterFormModel + { + Name = string.Empty, + CampaignId = SelectedCampaignId?.ToString() ?? string.Empty + }; + + CreateCharacterFormVersion++; + ShowCreateCharacterModal = true; + } + + private void OpenEditCharacterModal(CharacterSummary character) + { + EditingCharacterId = character.Id; + EditCharacterInitialModel = new CharacterFormModel + { + Name = character.Name, + CampaignId = character.CampaignId.ToString() + }; + + EditCharacterFormVersion++; + ShowEditCharacterModal = true; + } + + private void CloseCharacterModals() + { + ShowCreateCharacterModal = false; + ShowEditCharacterModal = false; + EditingCharacterId = null; + } + + private async Task OnCharacterCreatedAsync(Guid campaignId) + { + CloseCharacterModals(); + await ReloadCampaignsAsync(campaignId); + await RefreshCampaignScopeAsync(); + await SyncStateEventsAsync(); + SetStatus("Character created.", false); + } + + private async Task OnCharacterUpdatedAsync(Guid campaignId) + { + CloseCharacterModals(); + await ReloadCampaignsAsync(campaignId); + await RefreshCampaignScopeAsync(); + await SyncStateEventsAsync(); + SetStatus("Character updated.", false); + } + + private async Task SelectCharacterAsync(Guid characterId) + { + SelectedCharacterId = characterId; + SyncSelectedSkill(); + await EnsureSelectedCharacterActiveAsync(); + } + + private bool CanEditCharacter(CharacterSummary character) + { + return User is not null && (character.OwnerUserId == User.Id || IsCurrentUserGm); + } + + private static bool CanActivateCharacter(CharacterSummary character, UserSummary? user) + { + return user is not null && character.OwnerUserId == user.Id; + } + + private async Task EnsureSelectedCharacterActiveAsync() + { + if (!SelectedCharacterId.HasValue || SelectedCampaign is null) + { + return; + } + + var character = SelectedCampaign.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId.Value); + if (character is null || !CanActivateCharacter(character, User) || ActiveCharacterId == character.Id) + { + return; + } + + try + { + await ApiClient.RequestWithoutPayloadAsync("POST", $"/api/characters/{character.Id}/activate"); + ActiveCharacterId = character.Id; + } + catch (ApiRequestException ex) + { + SetStatus(ex.Message, true); + } + } + + private async Task OnSkillCreatedAsync(Guid _) + { + await RefreshCampaignScopeAsync(); + SetStatus("Skill created.", false); + } + + private async Task OnSkillUpdatedAsync(Guid skillId) + { + SelectedSkillId = skillId; + await RefreshCampaignScopeAsync(); + SetStatus("Skill updated.", false); + } + + private async Task RollSelectedSkillAsync() + { + if (SelectedSkill is null) + { + SetStatus("Select a skill to roll.", true); + return; + } + + IsMutating = true; + try + { + LastRoll = await ApiClient.RequestAsync( + "POST", + $"/api/skills/{SelectedSkill.Id}/roll", + new RollSkillRequest(RollVisibility)); + + await RefreshCampaignScopeAsync(); + SetStatus("Roll recorded.", false); + Announce("Roll result updated."); + } + catch (ApiRequestException ex) + { + SetStatus(ex.Message, true); + } + finally + { + IsMutating = false; + } + } + + private Task OnRollVisibilityChanged(string visibility) + { + RollVisibility = string.Equals(visibility, "private", StringComparison.OrdinalIgnoreCase) ? "private" : "public"; + return Task.CompletedTask; + } + + private void SelectSkill(Guid skillId) + { + SelectedSkillId = skillId; + } + + private bool CanEditSkill(SkillSummary skill) + { + if (SelectedCampaign is null) + { + return false; + } + + var character = SelectedCampaign.Characters.FirstOrDefault(c => c.Id == skill.CharacterId); + return character is not null && CanEditCharacter(character); + } + + private bool CanRollSkill(SkillSummary skill) + { + return CanEditSkill(skill); + } + + [JSInvokable] + public async Task OnStateEventReceived(long _) + { + if (StateRefreshInProgress) + { + return; + } + + StateRefreshInProgress = true; + try + { + await RefreshCampaignScopeAsync(); + } + finally + { + StateRefreshInProgress = false; + await InvokeAsync(StateHasChanged); + } + } + + [JSInvokable] + public Task OnConnectionStateChanged(string state) + { + ConnectionState = state switch + { + "connected" => "connected", + "reconnecting" => "reconnecting", + _ => "offline" + }; + + if (ConnectionState == "reconnecting") + { + Announce("Reconnecting to live updates."); + } + + if (ConnectionState == "offline") + { + Announce("Live updates offline. Use manual refresh."); + } + + return InvokeAsync(StateHasChanged); + } + + private async Task SyncStateEventsAsync() + { + if (User is null || !SelectedCampaignId.HasValue) + { + await StopStateEventsAsync(); + ConnectionState = "offline"; + return; + } + + DotNetRef ??= DotNetObjectReference.Create(this); + await JS.InvokeVoidAsync("rpgRollerApi.startStateEvents", SelectedCampaignId.Value.ToString(), DotNetRef); + ConnectionState = "reconnecting"; + } + + private async Task StopStateEventsAsync() + { + if (!HasInteractiveRenderStarted) + { + return; + } + + try + { + await JS.InvokeVoidAsync("rpgRollerApi.stopStateEvents"); + } + catch (JSDisconnectedException) + { + } + catch (InvalidOperationException ex) when (IsStaticRenderInteropException(ex)) + { + } + } + + public async ValueTask DisposeAsync() + { + await StopStateEventsAsync(); + DotNetRef?.Dispose(); + } + + private static bool IsStaticRenderInteropException(InvalidOperationException exception) + { + return exception.Message.Contains("statically rendered", StringComparison.OrdinalIgnoreCase); + } + + private void SyncSelectedCharacter() + { + if (SelectedCampaign is null || SelectedCampaign.Characters.Count == 0) + { + SelectedCharacterId = null; + return; + } + + var candidateIds = SelectedCampaign.Characters.Select(c => c.Id).ToHashSet(); + if (SelectedCharacterId.HasValue && candidateIds.Contains(SelectedCharacterId.Value)) + { + return; + } + + if (ActiveCharacterId.HasValue && candidateIds.Contains(ActiveCharacterId.Value)) + { + SelectedCharacterId = ActiveCharacterId; + return; + } + + SelectedCharacterId = SelectedCampaign.Characters[0].Id; + } + + private void SyncSelectedSkill() + { + var skills = SelectedCharacterSkills; + if (skills.Count == 0) + { + SelectedSkillId = null; + return; + } + + if (SelectedSkillId.HasValue && skills.Any(skill => skill.Id == SelectedSkillId.Value)) + { + return; + } + + SelectedSkillId = skills[0].Id; + } + + private string OwnerLabel(Guid ownerUserId) + { + if (User is not null && ownerUserId == User.Id) + { + return "You"; + } + + if (SelectedCampaign is not null && ownerUserId == SelectedCampaign.Gm.Id) + { + return $"{SelectedCampaign.Gm.DisplayName} (GM)"; + } + + return ownerUserId.ToString("N")[..8]; + } + + private string CharacterLabel(Guid characterId) + { + return SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == characterId)?.Name ?? "Hidden character"; + } + + private string SkillLabel(Guid skillId) + { + return SelectedCampaign?.Skills.FirstOrDefault(s => s.Id == skillId)?.Name ?? "Hidden skill"; + } + + private string SkillDefinitionLabel(SkillSummary skill) + { + if (!string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase)) + { + return skill.DiceRollDefinition; + } + + var fumbleLabel = skill.AllowFumble ? "fumble on" : "fumble off"; + return $"{skill.DiceRollDefinition} | wild {skill.WildDice}, {fumbleLabel}"; + } + + private string RollerLabel(CampaignLogEntry entry) + { + if (User is not null && entry.RollerUserId == User.Id) + { + return "You"; + } + + if (SelectedCampaign is not null && entry.RollerUserId == SelectedCampaign.Gm.Id) + { + return "GM"; + } + + return "Participant"; + } + + private string VisibilityLabel(CampaignLogEntry entry) + { + if (!string.Equals(entry.Visibility, "private", StringComparison.OrdinalIgnoreCase)) + { + return "Public"; + } + + if (User is not null && entry.RollerUserId == User.Id) + { + return "Private (you)"; + } + + return IsCurrentUserGm ? "Private (GM view)" : "Private"; + } + + private string VisibilityBadgeCssClass(CampaignLogEntry entry) + { + if (!string.Equals(entry.Visibility, "private", StringComparison.OrdinalIgnoreCase)) + { + return "public"; + } + + if (User is not null && entry.RollerUserId == User.Id) + { + return "private-self"; + } + + return IsCurrentUserGm ? "private-gm" : "private-generic"; + } + + private string LogEntryCssClass(CampaignLogEntry entry) + { + if (!string.Equals(entry.Visibility, "private", StringComparison.OrdinalIgnoreCase)) + { + return "public"; + } + + if (User is not null && entry.RollerUserId == User.Id) + { + return "private-self"; + } + + return IsCurrentUserGm ? "private-gm" : "private-generic"; + } + + private void ClearAuthenticatedState() + { + User = null; + ActiveCharacterId = null; + SelectedCampaignId = null; + SelectedCampaign = null; + Campaigns = []; + CampaignLog = []; + SelectedCharacterId = null; + SelectedSkillId = null; + LastRoll = null; + ShowCreateCharacterModal = false; + ShowEditCharacterModal = false; + CreateCharacterInitialModel = new(); + EditCharacterInitialModel = new(); + CreateCharacterFormVersion = 0; + EditCharacterFormVersion = 0; + } + + private void SetStatus(string message, bool isError) + { + StatusMessage = message; + StatusIsError = isError; + Announce(message); + } + + private void Announce(string message) + { + LiveAnnouncement = message; + } +} diff --git a/TECH.md b/TECH.md index 74eaa8b..0ff7314 100644 --- a/TECH.md +++ b/TECH.md @@ -92,11 +92,12 @@ This pattern is a strong baseline for low to medium scale and should be the defa ### 2.6 Frontend architecture - Blazor component tree rooted in `Components/App.razor` and `Components/Pages/Home.razor`. -- Home page logic split by concern with partials (`Home.State/Auth/Campaign/Character/Skill/Lifecycle/Realtime/Api/Presentation/Validation.cs`) to keep churn localized. +- `Home.razor` + `Home.razor.cs` are intentionally minimal and only manage loading/auth/workspace view-mode switching. +- Authenticated workspace UI plus workspace state/behavior are centralized in `Components/Pages/Workspace.razor`. - Form UX state uses reusable `FormState` containers in leaf controls (`HomeControls/*`) rather than parallel form/error/message property sets in `Home`. -- Concern controls execute their own auth/campaign/character/skill mutation workflows and notify `Home` only for shared-state refresh/orchestration. +- Concern controls execute their own auth/campaign/character/skill mutation workflows and notify the workspace host only for shared-state refresh/orchestration. - Skill management workflows are owned by `CharacterPanel` to keep character-skill behavior cohesive. -- Shared browser API interop is centralized in `RpgRollerApiClient` and reused by `Home` plus concern controls. +- Shared browser API interop is centralized in `RpgRollerApiClient` and reused by `Home`, `Workspace`, and concern controls. - Browser API calls and SSE are handled via `wwwroot/js/rpgroller-api.js` interop. - UI state is maintained server-side per circuit with session/tab persistence for campaign + screen selection. - SSE-driven campaign refresh with reconnect backoff and explicit offline/manual-refresh fallback.