Move Razor component logic into code-behind files
This commit is contained in:
4
FAQ.md
4
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`?
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ Frontend:
|
|||||||
- `RpgRoller/Components/Pages/Home.razor`: minimal gateway page (loading/auth/workspace switch)
|
- `RpgRoller/Components/Pages/Home.razor`: minimal gateway page (loading/auth/workspace switch)
|
||||||
- `RpgRoller/Components/Pages/Home.razor.cs`: single `Home` code-behind with only gateway/session-view orchestration
|
- `RpgRoller/Components/Pages/Home.razor.cs`: single `Home` code-behind with only gateway/session-view orchestration
|
||||||
- `RpgRoller/Components/Pages/Workspace.razor`: authenticated workspace UI and workspace-specific state/logic
|
- `RpgRoller/Components/Pages/Workspace.razor`: authenticated workspace UI and workspace-specific state/logic
|
||||||
|
- `RpgRoller/Components/**/*.razor.cs`: component code-behind classes (state, handlers, parameters, injected dependencies); `.razor` files remain markup-focused
|
||||||
- `RpgRoller/Components/Pages/Home.Models.cs`: reusable `FormState<TModel>` + page form models
|
- `RpgRoller/Components/Pages/Home.Models.cs`: reusable `FormState<TModel>` + page form models
|
||||||
- `RpgRoller/Components/Pages/HomeControls/`: auth, campaign management, play-screen, and modal controls extracted from `Home.razor`
|
- `RpgRoller/Components/Pages/HomeControls/`: auth, campaign management, play-screen, and modal controls extracted from `Home.razor`
|
||||||
- Form ownership model: controls own transient form/error state and execute their concern-specific API mutations directly
|
- Form ownership model: controls own transient form/error state and execute their concern-specific API mutations directly
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
@using RpgRoller.Components
|
@using RpgRoller.Components
|
||||||
@using RpgRoller.Components.Pages
|
@using RpgRoller.Components.Pages
|
||||||
@using RpgRoller.Contracts
|
@using RpgRoller.Contracts
|
||||||
@attribute [ExcludeFromCodeCoverage]
|
|
||||||
@inject RpgRollerApiClient ApiClient
|
|
||||||
|
|
||||||
<main class="auth-shell">
|
<main class="auth-shell">
|
||||||
<h1>RpgRoller</h1>
|
<h1>RpgRoller</h1>
|
||||||
@@ -66,114 +64,3 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@code {
|
|
||||||
private FormState<RegisterFormModel> RegisterState { get; } = new();
|
|
||||||
private FormState<LoginFormModel> 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<UserSummary>(
|
|
||||||
"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<UserSummary>(
|
|
||||||
"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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
123
RpgRoller/Components/Pages/HomeControls/AuthSection.razor.cs
Normal file
123
RpgRoller/Components/Pages/HomeControls/AuthSection.razor.cs
Normal file
@@ -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<RegisterFormModel> RegisterState { get; } = new();
|
||||||
|
private FormState<LoginFormModel> 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<UserSummary>(
|
||||||
|
"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<UserSummary>(
|
||||||
|
"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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
@using System.Diagnostics.CodeAnalysis
|
@using System.Diagnostics.CodeAnalysis
|
||||||
@using RpgRoller.Contracts
|
@using RpgRoller.Contracts
|
||||||
@attribute [ExcludeFromCodeCoverage]
|
|
||||||
|
|
||||||
<aside class="card log-panel">
|
<aside class="card log-panel">
|
||||||
<div class="section-head"><h2>Campaign Log</h2></div>
|
<div class="section-head"><h2>Campaign Log</h2></div>
|
||||||
@@ -28,29 +27,3 @@
|
|||||||
</ul>
|
</ul>
|
||||||
}
|
}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@code {
|
|
||||||
[Parameter]
|
|
||||||
public bool IsCampaignDataLoading { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public IReadOnlyList<CampaignLogEntry> CampaignLog { get; set; } = [];
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public Func<CampaignLogEntry, string> RollerLabel { get; set; } = _ => string.Empty;
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public Func<Guid, string> SkillLabel { get; set; } = _ => string.Empty;
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public Func<Guid, string> CharacterLabel { get; set; } = _ => string.Empty;
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public Func<CampaignLogEntry, string> LogEntryCssClass { get; set; } = _ => string.Empty;
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public Func<CampaignLogEntry, string> VisibilityLabel { get; set; } = _ => string.Empty;
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public Func<CampaignLogEntry, string> VisibilityBadgeCssClass { get; set; } = _ => string.Empty;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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<CampaignLogEntry> CampaignLog { get; set; } = [];
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public Func<CampaignLogEntry, string> RollerLabel { get; set; } = _ => string.Empty;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public Func<Guid, string> SkillLabel { get; set; } = _ => string.Empty;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public Func<Guid, string> CharacterLabel { get; set; } = _ => string.Empty;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public Func<CampaignLogEntry, string> LogEntryCssClass { get; set; } = _ => string.Empty;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public Func<CampaignLogEntry, string> VisibilityLabel { get; set; } = _ => string.Empty;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public Func<CampaignLogEntry, string> VisibilityBadgeCssClass { get; set; } = _ => string.Empty;
|
||||||
|
}
|
||||||
@@ -2,8 +2,6 @@
|
|||||||
@using RpgRoller.Components
|
@using RpgRoller.Components
|
||||||
@using RpgRoller.Components.Pages
|
@using RpgRoller.Components.Pages
|
||||||
@using RpgRoller.Contracts
|
@using RpgRoller.Contracts
|
||||||
@attribute [ExcludeFromCodeCoverage]
|
|
||||||
@inject RpgRollerApiClient ApiClient
|
|
||||||
|
|
||||||
<main class="management-screen">
|
<main class="management-screen">
|
||||||
<section class="card">
|
<section class="card">
|
||||||
@@ -98,93 +96,3 @@
|
|||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@code {
|
|
||||||
private FormState<CampaignFormModel> CampaignState { get; } = new();
|
|
||||||
private bool IsCreatingCampaign { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public IReadOnlyList<CampaignSummary> Campaigns { get; set; } = [];
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public Guid? SelectedCampaignId { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public string? SelectedCampaignName { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public CampaignDetails? SelectedCampaign { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public IReadOnlyList<RulesetDefinition> Rulesets { get; set; } = [];
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public bool IsMutating { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public Func<Guid, string> OwnerLabel { get; set; } = _ => string.Empty;
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public Func<CharacterSummary, bool> CanEditCharacter { get; set; } = _ => false;
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public EventCallback<ChangeEventArgs> CampaignSelectionChanged { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public EventCallback<Guid> CampaignCreated { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public EventCallback CreateCharacterRequested { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public EventCallback<CharacterSummary> 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<CampaignSummary>(
|
|
||||||
"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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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<CampaignFormModel> CampaignState { get; } = new();
|
||||||
|
private bool IsCreatingCampaign { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public IReadOnlyList<CampaignSummary> Campaigns { get; set; } = [];
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public Guid? SelectedCampaignId { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public string? SelectedCampaignName { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public CampaignDetails? SelectedCampaign { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public IReadOnlyList<RulesetDefinition> Rulesets { get; set; } = [];
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public bool IsMutating { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public Func<Guid, string> OwnerLabel { get; set; } = _ => string.Empty;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public Func<CharacterSummary, bool> CanEditCharacter { get; set; } = _ => false;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<ChangeEventArgs> CampaignSelectionChanged { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<Guid> CampaignCreated { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback CreateCharacterRequested { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<CharacterSummary> 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<CampaignSummary>(
|
||||||
|
"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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,6 @@
|
|||||||
@using RpgRoller.Components
|
@using RpgRoller.Components
|
||||||
@using RpgRoller.Components.Pages
|
@using RpgRoller.Components.Pages
|
||||||
@using RpgRoller.Contracts
|
@using RpgRoller.Contracts
|
||||||
@attribute [ExcludeFromCodeCoverage]
|
|
||||||
@inject RpgRollerApiClient ApiClient
|
|
||||||
|
|
||||||
@if (Visible)
|
@if (Visible)
|
||||||
{
|
{
|
||||||
@@ -41,109 +39,3 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@code {
|
|
||||||
private FormState<CharacterFormModel> 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<CampaignSummary> Campaigns { get; set; } = [];
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public bool IsMutating { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public EventCallback<Guid> 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<CharacterSummary>(
|
|
||||||
"PUT",
|
|
||||||
$"/api/characters/{EditingCharacterId.Value}",
|
|
||||||
new UpdateCharacterRequest(FormState.Model.Name.Trim(), campaignId));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
character = await ApiClient.RequestAsync<CharacterSummary>(
|
|
||||||
"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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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<CharacterFormModel> 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<CampaignSummary> Campaigns { get; set; } = [];
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public bool IsMutating { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<Guid> 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<CharacterSummary>(
|
||||||
|
"PUT",
|
||||||
|
$"/api/characters/{EditingCharacterId.Value}",
|
||||||
|
new UpdateCharacterRequest(FormState.Model.Name.Trim(), campaignId));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
character = await ApiClient.RequestAsync<CharacterSummary>(
|
||||||
|
"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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
@using System.Diagnostics.CodeAnalysis
|
@using System.Diagnostics.CodeAnalysis
|
||||||
@using RpgRoller.Components.Pages
|
@using RpgRoller.Components.Pages
|
||||||
@using RpgRoller.Contracts
|
@using RpgRoller.Contracts
|
||||||
@attribute [ExcludeFromCodeCoverage]
|
|
||||||
|
|
||||||
<section class="card character-panel">
|
<section class="card character-panel">
|
||||||
<div class="section-head"><h2>Character Context</h2></div>
|
<div class="section-head"><h2>Character Context</h2></div>
|
||||||
@@ -124,162 +123,3 @@
|
|||||||
IsMutating="IsMutating"
|
IsMutating="IsMutating"
|
||||||
SkillSaved="OnSkillUpdatedAsync"
|
SkillSaved="OnSkillUpdatedAsync"
|
||||||
CancelRequested="CloseSkillModals" />
|
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<SkillSummary> 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<string> RollVisibilityChanged { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public RollResult? LastRoll { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public Func<Guid, string> OwnerLabel { get; set; } = _ => string.Empty;
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public Func<SkillSummary, string> SkillDefinitionLabel { get; set; } = _ => string.Empty;
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public Func<CharacterSummary, bool> CanEditCharacter { get; set; } = _ => false;
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public Func<SkillSummary, bool> CanEditSkill { get; set; } = _ => false;
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public Func<SkillSummary, bool> CanRollSkill { get; set; } = _ => false;
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public EventCallback<Guid> CharacterSelected { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public EventCallback<Guid> SkillSelected { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public EventCallback<CharacterSummary> EditCharacterRequested { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public EventCallback<Guid> SkillCreated { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public EventCallback<Guid> 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
167
RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs
Normal file
167
RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs
Normal file
@@ -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<SkillSummary> 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<string> RollVisibilityChanged { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public RollResult? LastRoll { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public Func<Guid, string> OwnerLabel { get; set; } = _ => string.Empty;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public Func<SkillSummary, string> SkillDefinitionLabel { get; set; } = _ => string.Empty;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public Func<CharacterSummary, bool> CanEditCharacter { get; set; } = _ => false;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public Func<SkillSummary, bool> CanEditSkill { get; set; } = _ => false;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public Func<SkillSummary, bool> CanRollSkill { get; set; } = _ => false;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<Guid> CharacterSelected { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<Guid> SkillSelected { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<CharacterSummary> EditCharacterRequested { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<Guid> SkillCreated { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<Guid> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
@using System.Diagnostics.CodeAnalysis
|
@using System.Diagnostics.CodeAnalysis
|
||||||
@using RpgRoller.Contracts
|
@using RpgRoller.Contracts
|
||||||
@attribute [ExcludeFromCodeCoverage]
|
|
||||||
|
|
||||||
@if (Dice.Count > 0)
|
@if (Dice.Count > 0)
|
||||||
{
|
{
|
||||||
@@ -11,87 +10,3 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@code {
|
|
||||||
[Parameter]
|
|
||||||
public IReadOnlyList<RollDieResult> 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<string> { "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<string> { $"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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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<RollDieResult> 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<string> { "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<string> { $"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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,6 @@
|
|||||||
@using RpgRoller.Components
|
@using RpgRoller.Components
|
||||||
@using RpgRoller.Components.Pages
|
@using RpgRoller.Components.Pages
|
||||||
@using RpgRoller.Contracts
|
@using RpgRoller.Contracts
|
||||||
@attribute [ExcludeFromCodeCoverage]
|
|
||||||
@inject RpgRollerApiClient ApiClient
|
|
||||||
|
|
||||||
@if (Visible)
|
@if (Visible)
|
||||||
{
|
{
|
||||||
@@ -46,139 +44,3 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@code {
|
|
||||||
private FormState<SkillFormModel> 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<Guid> 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<SkillSummary>(
|
|
||||||
"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<SkillSummary>(
|
|
||||||
"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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
148
RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor.cs
Normal file
148
RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor.cs
Normal file
@@ -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<SkillFormModel> 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<Guid> 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<SkillSummary>(
|
||||||
|
"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<SkillSummary>(
|
||||||
|
"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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,6 @@
|
|||||||
@using RpgRoller.Components
|
@using RpgRoller.Components
|
||||||
@using RpgRoller.Components.Pages.HomeControls
|
@using RpgRoller.Components.Pages.HomeControls
|
||||||
@using RpgRoller.Contracts
|
@using RpgRoller.Contracts
|
||||||
@attribute [ExcludeFromCodeCoverage]
|
|
||||||
@implements IAsyncDisposable
|
|
||||||
@inject IJSRuntime JS
|
|
||||||
@inject RpgRollerApiClient ApiClient
|
|
||||||
|
|
||||||
<div class="@AppCssClass">
|
<div class="@AppCssClass">
|
||||||
<p class="sr-only" aria-live="polite">@LiveAnnouncement</p>
|
<p class="sr-only" aria-live="polite">@LiveAnnouncement</p>
|
||||||
@@ -141,779 +137,3 @@
|
|||||||
IsMutating="IsMutating"
|
IsMutating="IsMutating"
|
||||||
CharacterSaved="OnCharacterUpdatedAsync"
|
CharacterSaved="OnCharacterUpdatedAsync"
|
||||||
CancelRequested="CloseCharacterModals" />
|
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<CampaignSummary> Campaigns { get; set; } = [];
|
|
||||||
private List<CampaignLogEntry> CampaignLog { get; set; } = [];
|
|
||||||
private List<RulesetDefinition> 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<Workspace>? DotNetRef { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public EventCallback<string?> 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<SkillSummary> 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<string?>("rpgRollerApi.getSessionValue", ScreenSessionKey);
|
|
||||||
if (string.Equals(storedScreen, "management", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
CurrentScreen = "management";
|
|
||||||
}
|
|
||||||
|
|
||||||
var storedPanel = await JS.InvokeAsync<string?>("rpgRollerApi.getSessionValue", MobilePanelSessionKey);
|
|
||||||
if (string.Equals(storedPanel, "log", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
MobilePanel = "log";
|
|
||||||
}
|
|
||||||
|
|
||||||
Guid? preferredCampaignId = null;
|
|
||||||
var storedCampaignId = await JS.InvokeAsync<string?>("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<HealthResponse>("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<IReadOnlyList<RulesetDefinition>>("GET", "/api/rulesets")).ToList();
|
|
||||||
}
|
|
||||||
catch (ApiRequestException ex)
|
|
||||||
{
|
|
||||||
SetStatus(ex.Message, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<bool> 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<MeResponse?> TryGetMeAsync()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return await ApiClient.RequestAsync<MeResponse>("GET", "/api/me");
|
|
||||||
}
|
|
||||||
catch (ApiRequestException ex) when (ex.StatusCode == 401)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ReloadCampaignsAsync(Guid? preferredCampaignId)
|
|
||||||
{
|
|
||||||
var campaigns = await ApiClient.RequestAsync<IReadOnlyList<CampaignSummary>>("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<CampaignDetails>("GET", $"/api/campaigns/{campaignId}");
|
|
||||||
CampaignLog = (await ApiClient.RequestAsync<IReadOnlyList<CampaignLogEntry>>("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<RollResult>(
|
|
||||||
"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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
792
RpgRoller/Components/Pages/Workspace.razor.cs
Normal file
792
RpgRoller/Components/Pages/Workspace.razor.cs
Normal file
@@ -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<CampaignSummary> Campaigns { get; set; } = [];
|
||||||
|
private List<CampaignLogEntry> CampaignLog { get; set; } = [];
|
||||||
|
private List<RulesetDefinition> 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<Workspace>? DotNetRef { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<string?> 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<SkillSummary> 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<string?>("rpgRollerApi.getSessionValue", ScreenSessionKey);
|
||||||
|
if (string.Equals(storedScreen, "management", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
CurrentScreen = "management";
|
||||||
|
}
|
||||||
|
|
||||||
|
var storedPanel = await JS.InvokeAsync<string?>("rpgRollerApi.getSessionValue", MobilePanelSessionKey);
|
||||||
|
if (string.Equals(storedPanel, "log", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
MobilePanel = "log";
|
||||||
|
}
|
||||||
|
|
||||||
|
Guid? preferredCampaignId = null;
|
||||||
|
var storedCampaignId = await JS.InvokeAsync<string?>("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<HealthResponse>("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<IReadOnlyList<RulesetDefinition>>("GET", "/api/rulesets")).ToList();
|
||||||
|
}
|
||||||
|
catch (ApiRequestException ex)
|
||||||
|
{
|
||||||
|
SetStatus(ex.Message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> 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<MeResponse?> TryGetMeAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await ApiClient.RequestAsync<MeResponse>("GET", "/api/me");
|
||||||
|
}
|
||||||
|
catch (ApiRequestException ex) when (ex.StatusCode == 401)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReloadCampaignsAsync(Guid? preferredCampaignId)
|
||||||
|
{
|
||||||
|
var campaigns = await ApiClient.RequestAsync<IReadOnlyList<CampaignSummary>>("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<CampaignDetails>("GET", $"/api/campaigns/{campaignId}");
|
||||||
|
CampaignLog = (await ApiClient.RequestAsync<IReadOnlyList<CampaignLogEntry>>("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<RollResult>(
|
||||||
|
"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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
TECH.md
1
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
|
### 2.6 Frontend architecture
|
||||||
|
|
||||||
- Blazor component tree rooted in `Components/App.razor` and `Components/Pages/Home.razor`.
|
- 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.
|
- `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`.
|
- Authenticated workspace UI plus workspace state/behavior are centralized in `Components/Pages/Workspace.razor`.
|
||||||
- Form UX state uses reusable `FormState<TModel>` containers in leaf controls (`HomeControls/*`) rather than parallel form/error/message property sets in `Home`.
|
- Form UX state uses reusable `FormState<TModel>` containers in leaf controls (`HomeControls/*`) rather than parallel form/error/message property sets in `Home`.
|
||||||
|
|||||||
Reference in New Issue
Block a user