From d0da35a68c3bc47f63f1110cdb40ff9c78d7a30d Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Thu, 26 Feb 2026 10:45:32 +0100 Subject: [PATCH] Move Razor component logic into code-behind files --- FAQ.md | 4 + README.md | 1 + .../Pages/HomeControls/AuthSection.razor | 113 --- .../Pages/HomeControls/AuthSection.razor.cs | 123 +++ .../Pages/HomeControls/CampaignLogPanel.razor | 27 - .../HomeControls/CampaignLogPanel.razor.cs | 33 + .../CampaignManagementPanel.razor | 92 -- .../CampaignManagementPanel.razor.cs | 102 +++ .../HomeControls/CharacterFormModal.razor | 108 --- .../HomeControls/CharacterFormModal.razor.cs | 118 +++ .../Pages/HomeControls/CharacterPanel.razor | 160 ---- .../HomeControls/CharacterPanel.razor.cs | 167 ++++ .../Pages/HomeControls/RollDiceStrip.razor | 85 -- .../Pages/HomeControls/RollDiceStrip.razor.cs | 91 ++ .../Pages/HomeControls/SkillFormModal.razor | 138 --- .../HomeControls/SkillFormModal.razor.cs | 148 ++++ RpgRoller/Components/Pages/Workspace.razor | 780 ----------------- RpgRoller/Components/Pages/Workspace.razor.cs | 792 ++++++++++++++++++ TECH.md | 1 + 19 files changed, 1580 insertions(+), 1503 deletions(-) create mode 100644 RpgRoller/Components/Pages/HomeControls/AuthSection.razor.cs create mode 100644 RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs create mode 100644 RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor.cs create mode 100644 RpgRoller/Components/Pages/HomeControls/CharacterFormModal.razor.cs create mode 100644 RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs create mode 100644 RpgRoller/Components/Pages/HomeControls/RollDiceStrip.razor.cs create mode 100644 RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor.cs create mode 100644 RpgRoller/Components/Pages/Workspace.razor.cs diff --git a/FAQ.md b/FAQ.md index 4040d53..8335f7f 100644 --- a/FAQ.md +++ b/FAQ.md @@ -76,3 +76,7 @@ Authenticated application state and behavior were moved into `Components/Pages/W ## Why is auth form state kept in `AuthSection` instead of `Home`? Auth inputs, validation, and submit workflows are transient UI concerns, so they now live in `AuthSection`. `Home` keeps shared session/workspace state and cross-control refresh/orchestration only. + +## Why are there `.razor.cs` files next to Razor components? + +Component behavior was moved out of inline `@code` blocks into code-behind classes so `.razor` files stay markup-focused while state, parameters, handlers, and injected services live in typed C# files. diff --git a/README.md b/README.md index 063c708..c144b46 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,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/**/*.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` - Form ownership model: controls own transient form/error state and execute their concern-specific API mutations directly diff --git a/RpgRoller/Components/Pages/HomeControls/AuthSection.razor b/RpgRoller/Components/Pages/HomeControls/AuthSection.razor index e4dee8c..f82f995 100644 --- a/RpgRoller/Components/Pages/HomeControls/AuthSection.razor +++ b/RpgRoller/Components/Pages/HomeControls/AuthSection.razor @@ -2,8 +2,6 @@ @using RpgRoller.Components @using RpgRoller.Components.Pages @using RpgRoller.Contracts -@attribute [ExcludeFromCodeCoverage] -@inject RpgRollerApiClient ApiClient

RpgRoller

@@ -66,114 +64,3 @@
- -@code { - private FormState RegisterState { get; } = new(); - private FormState LoginState { get; } = new(); - private bool IsSubmitting { get; set; } - - [Parameter] - public string? StatusMessage { get; set; } - - [Parameter] - public bool StatusIsError { get; set; } - - [Parameter] - public EventCallback LoggedIn { get; set; } - - private async Task SubmitRegisterAsync() - { - RegisterState.ResetValidation(); - - var model = RegisterState.Model; - if (string.IsNullOrWhiteSpace(model.Username)) - { - RegisterState.Errors["username"] = "Username is required."; - } - - if (string.IsNullOrWhiteSpace(model.DisplayName)) - { - RegisterState.Errors["displayName"] = "Display name is required."; - } - - if (string.IsNullOrWhiteSpace(model.Password) || model.Password.Length < 8) - { - RegisterState.Errors["password"] = "Password must be at least 8 characters."; - } - - if (RegisterState.Errors.Count > 0) - { - RegisterState.ErrorMessage = "Resolve validation issues before submitting."; - return; - } - - IsSubmitting = true; - try - { - _ = await ApiClient.RequestAsync( - "POST", - "/api/auth/register", - new RegisterRequest(model.Username.Trim(), model.Password, model.DisplayName.Trim())); - - model.Password = string.Empty; - RegisterState.ErrorMessage = "Registration successful. You can log in now."; - } - catch (ApiRequestException ex) - { - if (ex.Message.Contains("already taken", StringComparison.OrdinalIgnoreCase)) - { - RegisterState.Errors["username"] = "Username is already taken. Choose another one."; - } - else - { - RegisterState.ErrorMessage = ex.Message; - } - } - finally - { - IsSubmitting = false; - } - } - - private async Task SubmitLoginAsync() - { - LoginState.ResetValidation(); - - var model = LoginState.Model; - if (string.IsNullOrWhiteSpace(model.Username)) - { - LoginState.Errors["username"] = "Username is required."; - } - - if (string.IsNullOrWhiteSpace(model.Password)) - { - LoginState.Errors["password"] = "Password is required."; - } - - if (LoginState.Errors.Count > 0) - { - LoginState.ErrorMessage = "Resolve validation issues before submitting."; - return; - } - - IsSubmitting = true; - try - { - _ = await ApiClient.RequestAsync( - "POST", - "/api/auth/login", - new LoginRequest(model.Username.Trim(), model.Password)); - - model.Password = string.Empty; - await LoggedIn.InvokeAsync(); - } - catch (ApiRequestException ex) - { - LoginState.ErrorMessage = ex.Message; - } - finally - { - IsSubmitting = false; - } - } -} diff --git a/RpgRoller/Components/Pages/HomeControls/AuthSection.razor.cs b/RpgRoller/Components/Pages/HomeControls/AuthSection.razor.cs new file mode 100644 index 0000000..1015eaf --- /dev/null +++ b/RpgRoller/Components/Pages/HomeControls/AuthSection.razor.cs @@ -0,0 +1,123 @@ +using Microsoft.AspNetCore.Components; +using RpgRoller.Components.Pages; +using RpgRoller.Components; +using RpgRoller.Contracts; +using System.Diagnostics.CodeAnalysis; + +namespace RpgRoller.Components.Pages.HomeControls; + +[ExcludeFromCodeCoverage] +public partial class AuthSection +{ + [Inject] + private RpgRollerApiClient ApiClient { get; set; } = default!; + + private FormState RegisterState { get; } = new(); + private FormState LoginState { get; } = new(); + private bool IsSubmitting { get; set; } + + [Parameter] + public string? StatusMessage { get; set; } + + [Parameter] + public bool StatusIsError { get; set; } + + [Parameter] + public EventCallback LoggedIn { get; set; } + + private async Task SubmitRegisterAsync() + { + RegisterState.ResetValidation(); + + var model = RegisterState.Model; + if (string.IsNullOrWhiteSpace(model.Username)) + { + RegisterState.Errors["username"] = "Username is required."; + } + + if (string.IsNullOrWhiteSpace(model.DisplayName)) + { + RegisterState.Errors["displayName"] = "Display name is required."; + } + + if (string.IsNullOrWhiteSpace(model.Password) || model.Password.Length < 8) + { + RegisterState.Errors["password"] = "Password must be at least 8 characters."; + } + + if (RegisterState.Errors.Count > 0) + { + RegisterState.ErrorMessage = "Resolve validation issues before submitting."; + return; + } + + IsSubmitting = true; + try + { + _ = await ApiClient.RequestAsync( + "POST", + "/api/auth/register", + new RegisterRequest(model.Username.Trim(), model.Password, model.DisplayName.Trim())); + + model.Password = string.Empty; + RegisterState.ErrorMessage = "Registration successful. You can log in now."; + } + catch (ApiRequestException ex) + { + if (ex.Message.Contains("already taken", StringComparison.OrdinalIgnoreCase)) + { + RegisterState.Errors["username"] = "Username is already taken. Choose another one."; + } + else + { + RegisterState.ErrorMessage = ex.Message; + } + } + finally + { + IsSubmitting = false; + } + } + + private async Task SubmitLoginAsync() + { + LoginState.ResetValidation(); + + var model = LoginState.Model; + if (string.IsNullOrWhiteSpace(model.Username)) + { + LoginState.Errors["username"] = "Username is required."; + } + + if (string.IsNullOrWhiteSpace(model.Password)) + { + LoginState.Errors["password"] = "Password is required."; + } + + if (LoginState.Errors.Count > 0) + { + LoginState.ErrorMessage = "Resolve validation issues before submitting."; + return; + } + + IsSubmitting = true; + try + { + _ = await ApiClient.RequestAsync( + "POST", + "/api/auth/login", + new LoginRequest(model.Username.Trim(), model.Password)); + + model.Password = string.Empty; + await LoggedIn.InvokeAsync(); + } + catch (ApiRequestException ex) + { + LoginState.ErrorMessage = ex.Message; + } + finally + { + IsSubmitting = false; + } + } +} diff --git a/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor b/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor index 2fb696b..a47a783 100644 --- a/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor +++ b/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor @@ -1,6 +1,5 @@ @using System.Diagnostics.CodeAnalysis @using RpgRoller.Contracts -@attribute [ExcludeFromCodeCoverage] - -@code { - [Parameter] - public bool IsCampaignDataLoading { get; set; } - - [Parameter] - public IReadOnlyList CampaignLog { get; set; } = []; - - [Parameter] - public Func RollerLabel { get; set; } = _ => string.Empty; - - [Parameter] - public Func SkillLabel { get; set; } = _ => string.Empty; - - [Parameter] - public Func CharacterLabel { get; set; } = _ => string.Empty; - - [Parameter] - public Func LogEntryCssClass { get; set; } = _ => string.Empty; - - [Parameter] - public Func VisibilityLabel { get; set; } = _ => string.Empty; - - [Parameter] - public Func VisibilityBadgeCssClass { get; set; } = _ => string.Empty; -} diff --git a/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs b/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs new file mode 100644 index 0000000..d2589db --- /dev/null +++ b/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Components; +using RpgRoller.Contracts; +using System.Diagnostics.CodeAnalysis; + +namespace RpgRoller.Components.Pages.HomeControls; + +[ExcludeFromCodeCoverage] +public partial class CampaignLogPanel +{ + [Parameter] + public bool IsCampaignDataLoading { get; set; } + + [Parameter] + public IReadOnlyList CampaignLog { get; set; } = []; + + [Parameter] + public Func RollerLabel { get; set; } = _ => string.Empty; + + [Parameter] + public Func SkillLabel { get; set; } = _ => string.Empty; + + [Parameter] + public Func CharacterLabel { get; set; } = _ => string.Empty; + + [Parameter] + public Func LogEntryCssClass { get; set; } = _ => string.Empty; + + [Parameter] + public Func VisibilityLabel { get; set; } = _ => string.Empty; + + [Parameter] + public Func VisibilityBadgeCssClass { get; set; } = _ => string.Empty; +} diff --git a/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor b/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor index d91df31..b0aafc5 100644 --- a/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor +++ b/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor @@ -2,8 +2,6 @@ @using RpgRoller.Components @using RpgRoller.Components.Pages @using RpgRoller.Contracts -@attribute [ExcludeFromCodeCoverage] -@inject RpgRollerApiClient ApiClient
@@ -98,93 +96,3 @@ }
- -@code { - private FormState CampaignState { get; } = new(); - private bool IsCreatingCampaign { get; set; } - - [Parameter] - public IReadOnlyList Campaigns { get; set; } = []; - - [Parameter] - public Guid? SelectedCampaignId { get; set; } - - [Parameter] - public string? SelectedCampaignName { get; set; } - - [Parameter] - public CampaignDetails? SelectedCampaign { get; set; } - - [Parameter] - public IReadOnlyList Rulesets { get; set; } = []; - - [Parameter] - public bool IsMutating { get; set; } - - [Parameter] - public Func OwnerLabel { get; set; } = _ => string.Empty; - - [Parameter] - public Func CanEditCharacter { get; set; } = _ => false; - - [Parameter] - public EventCallback CampaignSelectionChanged { get; set; } - - [Parameter] - public EventCallback CampaignCreated { get; set; } - - [Parameter] - public EventCallback CreateCharacterRequested { get; set; } - - [Parameter] - public EventCallback EditCharacterRequested { get; set; } - - protected override void OnParametersSet() - { - if (string.IsNullOrWhiteSpace(CampaignState.Model.RulesetId) && Rulesets.Count > 0) - { - CampaignState.Model.RulesetId = Rulesets[0].Id; - } - } - - private async Task SubmitCreateCampaignAsync() - { - CampaignState.ResetValidation(); - - if (string.IsNullOrWhiteSpace(CampaignState.Model.Name)) - { - CampaignState.Errors["name"] = "Campaign name is required."; - } - - if (string.IsNullOrWhiteSpace(CampaignState.Model.RulesetId)) - { - CampaignState.Errors["rulesetId"] = "Ruleset is required."; - } - - if (CampaignState.Errors.Count > 0) - { - CampaignState.ErrorMessage = "Resolve validation issues before submitting."; - return; - } - - IsCreatingCampaign = true; - try - { - var campaign = await ApiClient.RequestAsync( - "POST", - "/api/campaigns", - new CreateCampaignRequest(CampaignState.Model.Name.Trim(), CampaignState.Model.RulesetId)); - - CampaignState.Model.Name = string.Empty; - await CampaignCreated.InvokeAsync(campaign.Id); - } - catch (ApiRequestException ex) - { - CampaignState.ErrorMessage = ex.Message; - } - finally - { - IsCreatingCampaign = false; - } - } -} diff --git a/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor.cs b/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor.cs new file mode 100644 index 0000000..137094a --- /dev/null +++ b/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor.cs @@ -0,0 +1,102 @@ +using Microsoft.AspNetCore.Components; +using RpgRoller.Components.Pages; +using RpgRoller.Components; +using RpgRoller.Contracts; +using System.Diagnostics.CodeAnalysis; + +namespace RpgRoller.Components.Pages.HomeControls; + +[ExcludeFromCodeCoverage] +public partial class CampaignManagementPanel +{ + [Inject] + private RpgRollerApiClient ApiClient { get; set; } = default!; + + private FormState CampaignState { get; } = new(); + private bool IsCreatingCampaign { get; set; } + + [Parameter] + public IReadOnlyList Campaigns { get; set; } = []; + + [Parameter] + public Guid? SelectedCampaignId { get; set; } + + [Parameter] + public string? SelectedCampaignName { get; set; } + + [Parameter] + public CampaignDetails? SelectedCampaign { get; set; } + + [Parameter] + public IReadOnlyList Rulesets { get; set; } = []; + + [Parameter] + public bool IsMutating { get; set; } + + [Parameter] + public Func OwnerLabel { get; set; } = _ => string.Empty; + + [Parameter] + public Func CanEditCharacter { get; set; } = _ => false; + + [Parameter] + public EventCallback CampaignSelectionChanged { get; set; } + + [Parameter] + public EventCallback CampaignCreated { get; set; } + + [Parameter] + public EventCallback CreateCharacterRequested { get; set; } + + [Parameter] + public EventCallback EditCharacterRequested { get; set; } + + protected override void OnParametersSet() + { + if (string.IsNullOrWhiteSpace(CampaignState.Model.RulesetId) && Rulesets.Count > 0) + { + CampaignState.Model.RulesetId = Rulesets[0].Id; + } + } + + private async Task SubmitCreateCampaignAsync() + { + CampaignState.ResetValidation(); + + if (string.IsNullOrWhiteSpace(CampaignState.Model.Name)) + { + CampaignState.Errors["name"] = "Campaign name is required."; + } + + if (string.IsNullOrWhiteSpace(CampaignState.Model.RulesetId)) + { + CampaignState.Errors["rulesetId"] = "Ruleset is required."; + } + + if (CampaignState.Errors.Count > 0) + { + CampaignState.ErrorMessage = "Resolve validation issues before submitting."; + return; + } + + IsCreatingCampaign = true; + try + { + var campaign = await ApiClient.RequestAsync( + "POST", + "/api/campaigns", + new CreateCampaignRequest(CampaignState.Model.Name.Trim(), CampaignState.Model.RulesetId)); + + CampaignState.Model.Name = string.Empty; + await CampaignCreated.InvokeAsync(campaign.Id); + } + catch (ApiRequestException ex) + { + CampaignState.ErrorMessage = ex.Message; + } + finally + { + IsCreatingCampaign = false; + } + } +} diff --git a/RpgRoller/Components/Pages/HomeControls/CharacterFormModal.razor b/RpgRoller/Components/Pages/HomeControls/CharacterFormModal.razor index 873d568..49e6159 100644 --- a/RpgRoller/Components/Pages/HomeControls/CharacterFormModal.razor +++ b/RpgRoller/Components/Pages/HomeControls/CharacterFormModal.razor @@ -2,8 +2,6 @@ @using RpgRoller.Components @using RpgRoller.Components.Pages @using RpgRoller.Contracts -@attribute [ExcludeFromCodeCoverage] -@inject RpgRollerApiClient ApiClient @if (Visible) { @@ -41,109 +39,3 @@ } - -@code { - private FormState FormState { get; } = new(); - private int AppliedFormVersion { get; set; } = -1; - private bool IsSubmitting { get; set; } - - [Parameter] - public bool Visible { get; set; } - - [Parameter] - public string Title { get; set; } = "Character"; - - [Parameter] - public string SubmitLabel { get; set; } = "Save"; - - [Parameter] - public string NameInputId { get; set; } = "character-name"; - - [Parameter] - public string CampaignInputId { get; set; } = "character-campaign"; - - [Parameter] - public CharacterFormModel InitialModel { get; set; } = new(); - - [Parameter] - public int FormVersion { get; set; } - - [Parameter] - public Guid? EditingCharacterId { get; set; } - - [Parameter] - public IReadOnlyList Campaigns { get; set; } = []; - - [Parameter] - public bool IsMutating { get; set; } - - [Parameter] - public EventCallback CharacterSaved { get; set; } - - [Parameter] - public EventCallback CancelRequested { get; set; } - - protected override void OnParametersSet() - { - if (!Visible || FormVersion == AppliedFormVersion) - { - return; - } - - FormState.Model.Name = InitialModel.Name; - FormState.Model.CampaignId = InitialModel.CampaignId; - FormState.ResetValidation(); - AppliedFormVersion = FormVersion; - } - - private async Task SubmitAsync() - { - FormState.ResetValidation(); - - if (string.IsNullOrWhiteSpace(FormState.Model.Name)) - { - FormState.Errors["name"] = "Character name is required."; - } - - if (!Guid.TryParse(FormState.Model.CampaignId, out var campaignId)) - { - FormState.Errors["campaignId"] = "Campaign is required."; - } - - if (FormState.Errors.Count > 0) - { - FormState.ErrorMessage = "Resolve validation issues before submitting."; - return; - } - - IsSubmitting = true; - try - { - CharacterSummary character; - if (EditingCharacterId.HasValue) - { - character = await ApiClient.RequestAsync( - "PUT", - $"/api/characters/{EditingCharacterId.Value}", - new UpdateCharacterRequest(FormState.Model.Name.Trim(), campaignId)); - } - else - { - character = await ApiClient.RequestAsync( - "POST", - "/api/characters", - new CreateCharacterRequest(FormState.Model.Name.Trim(), campaignId)); - } - - await CharacterSaved.InvokeAsync(character.CampaignId); - } - catch (ApiRequestException ex) - { - FormState.ErrorMessage = ex.Message; - } - finally - { - IsSubmitting = false; - } - } -} diff --git a/RpgRoller/Components/Pages/HomeControls/CharacterFormModal.razor.cs b/RpgRoller/Components/Pages/HomeControls/CharacterFormModal.razor.cs new file mode 100644 index 0000000..95fc92c --- /dev/null +++ b/RpgRoller/Components/Pages/HomeControls/CharacterFormModal.razor.cs @@ -0,0 +1,118 @@ +using Microsoft.AspNetCore.Components; +using RpgRoller.Components.Pages; +using RpgRoller.Components; +using RpgRoller.Contracts; +using System.Diagnostics.CodeAnalysis; + +namespace RpgRoller.Components.Pages.HomeControls; + +[ExcludeFromCodeCoverage] +public partial class CharacterFormModal +{ + [Inject] + private RpgRollerApiClient ApiClient { get; set; } = default!; + + private FormState FormState { get; } = new(); + private int AppliedFormVersion { get; set; } = -1; + private bool IsSubmitting { get; set; } + + [Parameter] + public bool Visible { get; set; } + + [Parameter] + public string Title { get; set; } = "Character"; + + [Parameter] + public string SubmitLabel { get; set; } = "Save"; + + [Parameter] + public string NameInputId { get; set; } = "character-name"; + + [Parameter] + public string CampaignInputId { get; set; } = "character-campaign"; + + [Parameter] + public CharacterFormModel InitialModel { get; set; } = new(); + + [Parameter] + public int FormVersion { get; set; } + + [Parameter] + public Guid? EditingCharacterId { get; set; } + + [Parameter] + public IReadOnlyList Campaigns { get; set; } = []; + + [Parameter] + public bool IsMutating { get; set; } + + [Parameter] + public EventCallback CharacterSaved { get; set; } + + [Parameter] + public EventCallback CancelRequested { get; set; } + + protected override void OnParametersSet() + { + if (!Visible || FormVersion == AppliedFormVersion) + { + return; + } + + FormState.Model.Name = InitialModel.Name; + FormState.Model.CampaignId = InitialModel.CampaignId; + FormState.ResetValidation(); + AppliedFormVersion = FormVersion; + } + + private async Task SubmitAsync() + { + FormState.ResetValidation(); + + if (string.IsNullOrWhiteSpace(FormState.Model.Name)) + { + FormState.Errors["name"] = "Character name is required."; + } + + if (!Guid.TryParse(FormState.Model.CampaignId, out var campaignId)) + { + FormState.Errors["campaignId"] = "Campaign is required."; + } + + if (FormState.Errors.Count > 0) + { + FormState.ErrorMessage = "Resolve validation issues before submitting."; + return; + } + + IsSubmitting = true; + try + { + CharacterSummary character; + if (EditingCharacterId.HasValue) + { + character = await ApiClient.RequestAsync( + "PUT", + $"/api/characters/{EditingCharacterId.Value}", + new UpdateCharacterRequest(FormState.Model.Name.Trim(), campaignId)); + } + else + { + character = await ApiClient.RequestAsync( + "POST", + "/api/characters", + new CreateCharacterRequest(FormState.Model.Name.Trim(), campaignId)); + } + + await CharacterSaved.InvokeAsync(character.CampaignId); + } + catch (ApiRequestException ex) + { + FormState.ErrorMessage = ex.Message; + } + finally + { + IsSubmitting = false; + } + } +} diff --git a/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor b/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor index f7e0fa5..2107d70 100644 --- a/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor +++ b/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor @@ -1,7 +1,6 @@ @using System.Diagnostics.CodeAnalysis @using RpgRoller.Components.Pages @using RpgRoller.Contracts -@attribute [ExcludeFromCodeCoverage]

Character Context

@@ -124,162 +123,3 @@ IsMutating="IsMutating" SkillSaved="OnSkillUpdatedAsync" CancelRequested="CloseSkillModals" /> - -@code { - private bool ShowCreateSkillModal { get; set; } - private bool ShowEditSkillModal { get; set; } - private Guid? EditingSkillId { get; set; } - private SkillFormModel CreateSkillInitialModel { get; set; } = new(); - private SkillFormModel EditSkillInitialModel { get; set; } = new(); - private int CreateSkillFormVersion { get; set; } - private int EditSkillFormVersion { get; set; } - - [Parameter] - public bool IsCampaignDataLoading { get; set; } - - [Parameter] - public CampaignDetails? SelectedCampaign { get; set; } - - [Parameter] - public Guid? SelectedCharacterId { get; set; } - - [Parameter] - public CharacterSummary? SelectedCharacter { get; set; } - - [Parameter] - public bool IsMutating { get; set; } - - [Parameter] - public IReadOnlyList SelectedCharacterSkills { get; set; } = []; - - [Parameter] - public Guid? SelectedSkillId { get; set; } - - [Parameter] - public SkillSummary? SelectedSkill { get; set; } - - [Parameter] - public bool IsD6 { get; set; } - - [Parameter] - public string RollVisibility { get; set; } = "public"; - - [Parameter] - public EventCallback RollVisibilityChanged { get; set; } - - [Parameter] - public RollResult? LastRoll { get; set; } - - [Parameter] - public Func OwnerLabel { get; set; } = _ => string.Empty; - - [Parameter] - public Func SkillDefinitionLabel { get; set; } = _ => string.Empty; - - [Parameter] - public Func CanEditCharacter { get; set; } = _ => false; - - [Parameter] - public Func CanEditSkill { get; set; } = _ => false; - - [Parameter] - public Func CanRollSkill { get; set; } = _ => false; - - [Parameter] - public EventCallback CharacterSelected { get; set; } - - [Parameter] - public EventCallback SkillSelected { get; set; } - - [Parameter] - public EventCallback EditCharacterRequested { get; set; } - - [Parameter] - public EventCallback SkillCreated { get; set; } - - [Parameter] - public EventCallback SkillUpdated { get; set; } - - [Parameter] - public EventCallback RollRequested { get; set; } - - private void OpenCreateSkillModal() - { - CreateSkillInitialModel = new SkillFormModel - { - Name = string.Empty, - DiceRollDefinition = string.Empty, - WildDice = IsD6 ? 1 : 0, - AllowFumble = IsD6 - }; - - CreateSkillFormVersion++; - ShowCreateSkillModal = true; - } - - private void OpenEditSkillModal() - { - if (SelectedSkill is null) - { - return; - } - - EditingSkillId = SelectedSkill.Id; - EditSkillInitialModel = new SkillFormModel - { - Name = SelectedSkill.Name, - DiceRollDefinition = SelectedSkill.DiceRollDefinition, - WildDice = SelectedSkill.WildDice, - AllowFumble = SelectedSkill.AllowFumble - }; - - EditSkillFormVersion++; - ShowEditSkillModal = true; - } - - private void CloseSkillModals() - { - ShowCreateSkillModal = false; - ShowEditSkillModal = false; - EditingSkillId = null; - } - - private async Task OnSkillCreatedAsync(Guid skillId) - { - CloseSkillModals(); - await SkillCreated.InvokeAsync(skillId); - } - - private async Task OnSkillUpdatedAsync(Guid skillId) - { - CloseSkillModals(); - await SkillUpdated.InvokeAsync(skillId); - } - - private async Task OnRollVisibilityChangedAsync(ChangeEventArgs args) - { - var selectedVisibility = args.Value?.ToString() ?? "public"; - await RollVisibilityChanged.InvokeAsync(selectedVisibility); - } - - private async Task OnRollSubmitAsync() - { - await RollRequested.InvokeAsync(); - } - - private static string InitialsFor(string value) - { - var words = value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (words.Length == 0) - { - return "?"; - } - - if (words.Length == 1) - { - return words[0].Length >= 2 ? words[0][..2].ToUpperInvariant() : words[0].ToUpperInvariant(); - } - - return string.Concat(words[0][0], words[1][0]).ToUpperInvariant(); - } -} diff --git a/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs b/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs new file mode 100644 index 0000000..ee3b593 --- /dev/null +++ b/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs @@ -0,0 +1,167 @@ +using Microsoft.AspNetCore.Components; +using RpgRoller.Components.Pages; +using RpgRoller.Contracts; +using System.Diagnostics.CodeAnalysis; + +namespace RpgRoller.Components.Pages.HomeControls; + +[ExcludeFromCodeCoverage] +public partial class CharacterPanel +{ + private bool ShowCreateSkillModal { get; set; } + private bool ShowEditSkillModal { get; set; } + private Guid? EditingSkillId { get; set; } + private SkillFormModel CreateSkillInitialModel { get; set; } = new(); + private SkillFormModel EditSkillInitialModel { get; set; } = new(); + private int CreateSkillFormVersion { get; set; } + private int EditSkillFormVersion { get; set; } + + [Parameter] + public bool IsCampaignDataLoading { get; set; } + + [Parameter] + public CampaignDetails? SelectedCampaign { get; set; } + + [Parameter] + public Guid? SelectedCharacterId { get; set; } + + [Parameter] + public CharacterSummary? SelectedCharacter { get; set; } + + [Parameter] + public bool IsMutating { get; set; } + + [Parameter] + public IReadOnlyList SelectedCharacterSkills { get; set; } = []; + + [Parameter] + public Guid? SelectedSkillId { get; set; } + + [Parameter] + public SkillSummary? SelectedSkill { get; set; } + + [Parameter] + public bool IsD6 { get; set; } + + [Parameter] + public string RollVisibility { get; set; } = "public"; + + [Parameter] + public EventCallback RollVisibilityChanged { get; set; } + + [Parameter] + public RollResult? LastRoll { get; set; } + + [Parameter] + public Func OwnerLabel { get; set; } = _ => string.Empty; + + [Parameter] + public Func SkillDefinitionLabel { get; set; } = _ => string.Empty; + + [Parameter] + public Func CanEditCharacter { get; set; } = _ => false; + + [Parameter] + public Func CanEditSkill { get; set; } = _ => false; + + [Parameter] + public Func CanRollSkill { get; set; } = _ => false; + + [Parameter] + public EventCallback CharacterSelected { get; set; } + + [Parameter] + public EventCallback SkillSelected { get; set; } + + [Parameter] + public EventCallback EditCharacterRequested { get; set; } + + [Parameter] + public EventCallback SkillCreated { get; set; } + + [Parameter] + public EventCallback SkillUpdated { get; set; } + + [Parameter] + public EventCallback RollRequested { get; set; } + + private void OpenCreateSkillModal() + { + CreateSkillInitialModel = new SkillFormModel + { + Name = string.Empty, + DiceRollDefinition = string.Empty, + WildDice = IsD6 ? 1 : 0, + AllowFumble = IsD6 + }; + + CreateSkillFormVersion++; + ShowCreateSkillModal = true; + } + + private void OpenEditSkillModal() + { + if (SelectedSkill is null) + { + return; + } + + EditingSkillId = SelectedSkill.Id; + EditSkillInitialModel = new SkillFormModel + { + Name = SelectedSkill.Name, + DiceRollDefinition = SelectedSkill.DiceRollDefinition, + WildDice = SelectedSkill.WildDice, + AllowFumble = SelectedSkill.AllowFumble + }; + + EditSkillFormVersion++; + ShowEditSkillModal = true; + } + + private void CloseSkillModals() + { + ShowCreateSkillModal = false; + ShowEditSkillModal = false; + EditingSkillId = null; + } + + private async Task OnSkillCreatedAsync(Guid skillId) + { + CloseSkillModals(); + await SkillCreated.InvokeAsync(skillId); + } + + private async Task OnSkillUpdatedAsync(Guid skillId) + { + CloseSkillModals(); + await SkillUpdated.InvokeAsync(skillId); + } + + private async Task OnRollVisibilityChangedAsync(ChangeEventArgs args) + { + var selectedVisibility = args.Value?.ToString() ?? "public"; + await RollVisibilityChanged.InvokeAsync(selectedVisibility); + } + + private async Task OnRollSubmitAsync() + { + await RollRequested.InvokeAsync(); + } + + private static string InitialsFor(string value) + { + var words = value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (words.Length == 0) + { + return "?"; + } + + if (words.Length == 1) + { + return words[0].Length >= 2 ? words[0][..2].ToUpperInvariant() : words[0].ToUpperInvariant(); + } + + return string.Concat(words[0][0], words[1][0]).ToUpperInvariant(); + } +} diff --git a/RpgRoller/Components/Pages/HomeControls/RollDiceStrip.razor b/RpgRoller/Components/Pages/HomeControls/RollDiceStrip.razor index c4a4f69..ed9325c 100644 --- a/RpgRoller/Components/Pages/HomeControls/RollDiceStrip.razor +++ b/RpgRoller/Components/Pages/HomeControls/RollDiceStrip.razor @@ -1,6 +1,5 @@ @using System.Diagnostics.CodeAnalysis @using RpgRoller.Contracts -@attribute [ExcludeFromCodeCoverage] @if (Dice.Count > 0) { @@ -11,87 +10,3 @@ } } - -@code { - [Parameter] - public IReadOnlyList Dice { get; set; } = []; - - [Parameter] - public string AriaLabel { get; set; } = "Rolled dice"; - - private static string RollDieGlyph(int roll) - { - return roll switch - { - 1 => "\u2680", - 2 => "\u2681", - 3 => "\u2682", - 4 => "\u2683", - 5 => "\u2684", - 6 => "\u2685", - _ => roll.ToString() - }; - } - - private static string RollDieCssClass(RollDieResult die) - { - var classes = new List { "die-chip" }; - if (die.Wild) - { - classes.Add("wild"); - } - - if (die.Crit) - { - classes.Add("crit"); - } - - if (die.Fumble) - { - classes.Add("fumble"); - } - - if (die.Removed) - { - classes.Add("removed"); - } - - if (die.Added) - { - classes.Add("added"); - } - - return string.Join(" ", classes); - } - - private static string RollDieTitle(RollDieResult die) - { - var labels = new List { $"Roll {die.Roll}" }; - if (die.Wild) - { - labels.Add("wild"); - } - - if (die.Crit) - { - labels.Add("critical"); - } - - if (die.Fumble) - { - labels.Add("fumble"); - } - - if (die.Removed) - { - labels.Add("removed"); - } - - if (die.Added) - { - labels.Add("added"); - } - - return string.Join(", ", labels); - } -} diff --git a/RpgRoller/Components/Pages/HomeControls/RollDiceStrip.razor.cs b/RpgRoller/Components/Pages/HomeControls/RollDiceStrip.razor.cs new file mode 100644 index 0000000..48a2700 --- /dev/null +++ b/RpgRoller/Components/Pages/HomeControls/RollDiceStrip.razor.cs @@ -0,0 +1,91 @@ +using Microsoft.AspNetCore.Components; +using RpgRoller.Contracts; +using System.Diagnostics.CodeAnalysis; + +namespace RpgRoller.Components.Pages.HomeControls; + +[ExcludeFromCodeCoverage] +public partial class RollDiceStrip +{ + [Parameter] + public IReadOnlyList Dice { get; set; } = []; + + [Parameter] + public string AriaLabel { get; set; } = "Rolled dice"; + + private static string RollDieGlyph(int roll) + { + return roll switch + { + 1 => "\u2680", + 2 => "\u2681", + 3 => "\u2682", + 4 => "\u2683", + 5 => "\u2684", + 6 => "\u2685", + _ => roll.ToString() + }; + } + + private static string RollDieCssClass(RollDieResult die) + { + var classes = new List { "die-chip" }; + if (die.Wild) + { + classes.Add("wild"); + } + + if (die.Crit) + { + classes.Add("crit"); + } + + if (die.Fumble) + { + classes.Add("fumble"); + } + + if (die.Removed) + { + classes.Add("removed"); + } + + if (die.Added) + { + classes.Add("added"); + } + + return string.Join(" ", classes); + } + + private static string RollDieTitle(RollDieResult die) + { + var labels = new List { $"Roll {die.Roll}" }; + if (die.Wild) + { + labels.Add("wild"); + } + + if (die.Crit) + { + labels.Add("critical"); + } + + if (die.Fumble) + { + labels.Add("fumble"); + } + + if (die.Removed) + { + labels.Add("removed"); + } + + if (die.Added) + { + labels.Add("added"); + } + + return string.Join(", ", labels); + } +} diff --git a/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor b/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor index 207ed69..6a98173 100644 --- a/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor +++ b/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor @@ -2,8 +2,6 @@ @using RpgRoller.Components @using RpgRoller.Components.Pages @using RpgRoller.Contracts -@attribute [ExcludeFromCodeCoverage] -@inject RpgRollerApiClient ApiClient @if (Visible) { @@ -46,139 +44,3 @@
} - -@code { - private FormState FormState { get; } = new(); - private int AppliedFormVersion { get; set; } = -1; - private bool IsSubmitting { get; set; } - - [Parameter] - public bool Visible { get; set; } - - [Parameter] - public bool IsD6 { get; set; } - - [Parameter] - public string Title { get; set; } = "Skill"; - - [Parameter] - public string SubmitLabel { get; set; } = "Save"; - - [Parameter] - public string NameInputId { get; set; } = "skill-name"; - - [Parameter] - public string ExpressionInputId { get; set; } = "skill-expression"; - - [Parameter] - public string WildDiceInputId { get; set; } = "skill-wild"; - - [Parameter] - public string AllowFumbleInputId { get; set; } = "skill-fumble"; - - [Parameter] - public SkillFormModel InitialModel { get; set; } = new(); - - [Parameter] - public int FormVersion { get; set; } - - [Parameter] - public Guid? SelectedCharacterId { get; set; } - - [Parameter] - public Guid? EditingSkillId { get; set; } - - [Parameter] - public bool IsMutating { get; set; } - - [Parameter] - public EventCallback SkillSaved { get; set; } - - [Parameter] - public EventCallback CancelRequested { get; set; } - - protected override void OnParametersSet() - { - if (!Visible || FormVersion == AppliedFormVersion) - { - return; - } - - FormState.Model.Name = InitialModel.Name; - FormState.Model.DiceRollDefinition = InitialModel.DiceRollDefinition; - FormState.Model.WildDice = InitialModel.WildDice; - FormState.Model.AllowFumble = InitialModel.AllowFumble; - FormState.ResetValidation(); - AppliedFormVersion = FormVersion; - } - - private async Task SubmitAsync() - { - FormState.ResetValidation(); - - if (string.IsNullOrWhiteSpace(FormState.Model.Name)) - { - FormState.Errors["name"] = "Skill name is required."; - } - - if (string.IsNullOrWhiteSpace(FormState.Model.DiceRollDefinition)) - { - FormState.Errors["diceRollDefinition"] = "Expression is required."; - } - - if (IsD6 && FormState.Model.WildDice < 1) - { - FormState.Errors["wildDice"] = "D6 skills require at least one wild die."; - } - - if (FormState.Errors.Count > 0) - { - FormState.ErrorMessage = "Resolve validation issues before submitting."; - return; - } - - IsSubmitting = true; - try - { - SkillSummary skill; - if (EditingSkillId.HasValue) - { - skill = await ApiClient.RequestAsync( - "PUT", - $"/api/skills/{EditingSkillId.Value}", - new UpdateSkillRequest( - FormState.Model.Name.Trim(), - FormState.Model.DiceRollDefinition.Trim(), - FormState.Model.WildDice, - FormState.Model.AllowFumble)); - } - else - { - if (!SelectedCharacterId.HasValue) - { - FormState.ErrorMessage = "Select a character first."; - return; - } - - skill = await ApiClient.RequestAsync( - "POST", - $"/api/characters/{SelectedCharacterId.Value}/skills", - new CreateSkillRequest( - FormState.Model.Name.Trim(), - FormState.Model.DiceRollDefinition.Trim(), - FormState.Model.WildDice, - FormState.Model.AllowFumble)); - } - - await SkillSaved.InvokeAsync(skill.Id); - } - catch (ApiRequestException ex) - { - FormState.ErrorMessage = ex.Message; - } - finally - { - IsSubmitting = false; - } - } -} diff --git a/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor.cs b/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor.cs new file mode 100644 index 0000000..d9d3a9e --- /dev/null +++ b/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor.cs @@ -0,0 +1,148 @@ +using Microsoft.AspNetCore.Components; +using RpgRoller.Components.Pages; +using RpgRoller.Components; +using RpgRoller.Contracts; +using System.Diagnostics.CodeAnalysis; + +namespace RpgRoller.Components.Pages.HomeControls; + +[ExcludeFromCodeCoverage] +public partial class SkillFormModal +{ + [Inject] + private RpgRollerApiClient ApiClient { get; set; } = default!; + + private FormState FormState { get; } = new(); + private int AppliedFormVersion { get; set; } = -1; + private bool IsSubmitting { get; set; } + + [Parameter] + public bool Visible { get; set; } + + [Parameter] + public bool IsD6 { get; set; } + + [Parameter] + public string Title { get; set; } = "Skill"; + + [Parameter] + public string SubmitLabel { get; set; } = "Save"; + + [Parameter] + public string NameInputId { get; set; } = "skill-name"; + + [Parameter] + public string ExpressionInputId { get; set; } = "skill-expression"; + + [Parameter] + public string WildDiceInputId { get; set; } = "skill-wild"; + + [Parameter] + public string AllowFumbleInputId { get; set; } = "skill-fumble"; + + [Parameter] + public SkillFormModel InitialModel { get; set; } = new(); + + [Parameter] + public int FormVersion { get; set; } + + [Parameter] + public Guid? SelectedCharacterId { get; set; } + + [Parameter] + public Guid? EditingSkillId { get; set; } + + [Parameter] + public bool IsMutating { get; set; } + + [Parameter] + public EventCallback SkillSaved { get; set; } + + [Parameter] + public EventCallback CancelRequested { get; set; } + + protected override void OnParametersSet() + { + if (!Visible || FormVersion == AppliedFormVersion) + { + return; + } + + FormState.Model.Name = InitialModel.Name; + FormState.Model.DiceRollDefinition = InitialModel.DiceRollDefinition; + FormState.Model.WildDice = InitialModel.WildDice; + FormState.Model.AllowFumble = InitialModel.AllowFumble; + FormState.ResetValidation(); + AppliedFormVersion = FormVersion; + } + + private async Task SubmitAsync() + { + FormState.ResetValidation(); + + if (string.IsNullOrWhiteSpace(FormState.Model.Name)) + { + FormState.Errors["name"] = "Skill name is required."; + } + + if (string.IsNullOrWhiteSpace(FormState.Model.DiceRollDefinition)) + { + FormState.Errors["diceRollDefinition"] = "Expression is required."; + } + + if (IsD6 && FormState.Model.WildDice < 1) + { + FormState.Errors["wildDice"] = "D6 skills require at least one wild die."; + } + + if (FormState.Errors.Count > 0) + { + FormState.ErrorMessage = "Resolve validation issues before submitting."; + return; + } + + IsSubmitting = true; + try + { + SkillSummary skill; + if (EditingSkillId.HasValue) + { + skill = await ApiClient.RequestAsync( + "PUT", + $"/api/skills/{EditingSkillId.Value}", + new UpdateSkillRequest( + FormState.Model.Name.Trim(), + FormState.Model.DiceRollDefinition.Trim(), + FormState.Model.WildDice, + FormState.Model.AllowFumble)); + } + else + { + if (!SelectedCharacterId.HasValue) + { + FormState.ErrorMessage = "Select a character first."; + return; + } + + skill = await ApiClient.RequestAsync( + "POST", + $"/api/characters/{SelectedCharacterId.Value}/skills", + new CreateSkillRequest( + FormState.Model.Name.Trim(), + FormState.Model.DiceRollDefinition.Trim(), + FormState.Model.WildDice, + FormState.Model.AllowFumble)); + } + + await SkillSaved.InvokeAsync(skill.Id); + } + catch (ApiRequestException ex) + { + FormState.ErrorMessage = ex.Message; + } + finally + { + IsSubmitting = false; + } + } +} diff --git a/RpgRoller/Components/Pages/Workspace.razor b/RpgRoller/Components/Pages/Workspace.razor index 5fdf6d2..dc1542d 100644 --- a/RpgRoller/Components/Pages/Workspace.razor +++ b/RpgRoller/Components/Pages/Workspace.razor @@ -3,10 +3,6 @@ @using RpgRoller.Components @using RpgRoller.Components.Pages.HomeControls @using RpgRoller.Contracts -@attribute [ExcludeFromCodeCoverage] -@implements IAsyncDisposable -@inject IJSRuntime JS -@inject RpgRollerApiClient ApiClient

@LiveAnnouncement

@@ -141,779 +137,3 @@ IsMutating="IsMutating" CharacterSaved="OnCharacterUpdatedAsync" CancelRequested="CloseCharacterModals" /> - -@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/RpgRoller/Components/Pages/Workspace.razor.cs b/RpgRoller/Components/Pages/Workspace.razor.cs new file mode 100644 index 0000000..a8594b2 --- /dev/null +++ b/RpgRoller/Components/Pages/Workspace.razor.cs @@ -0,0 +1,792 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; +using RpgRoller.Components.Pages.HomeControls; +using RpgRoller.Components; +using RpgRoller.Contracts; +using System.Diagnostics.CodeAnalysis; + +namespace RpgRoller.Components.Pages; + +[ExcludeFromCodeCoverage] +public partial class Workspace : IAsyncDisposable +{ + [Inject] + private IJSRuntime JS { get; set; } = default!; + + [Inject] + private RpgRollerApiClient ApiClient { get; set; } = default!; + + 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 0ff7314..8caf496 100644 --- a/TECH.md +++ b/TECH.md @@ -92,6 +92,7 @@ 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`. +- Razor components are split into markup-first `.razor` files with behavior/state in paired `.razor.cs` code-behind classes. - `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`.