Move concern workflows from Home into concern controls
This commit is contained in:
2
FAQ.md
2
FAQ.md
@@ -74,4 +74,4 @@ There is no separate activate button in Play. The selected character in the char
|
|||||||
|
|
||||||
## Why is auth form state kept in `AuthSection` instead of `Home`?
|
## Why is auth form state kept in `AuthSection` instead of `Home`?
|
||||||
|
|
||||||
Auth inputs and inline validation are transient UI concerns, so they now live in `AuthSection`. `Home` keeps only session/workspace state and API workflows, and returns `FormSubmissionResult` back to controls for display.
|
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.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Tracking against `UX.md` tasks and decisions.
|
|||||||
- Runtime frontend stack: Blazor (`RpgRoller/Components/*`) + browser JS interop (`wwwroot/js/rpgroller-api.js`)
|
- Runtime frontend stack: Blazor (`RpgRoller/Components/*`) + browser JS interop (`wwwroot/js/rpgroller-api.js`)
|
||||||
- Legacy TypeScript frontend/runtime artifacts: removed
|
- Legacy TypeScript frontend/runtime artifacts: removed
|
||||||
- Home page orchestration split by concern (`Home.*.cs` partials + `HomeControls/*`) to reduce merge churn and keep auth/campaign/character/skill flows isolated.
|
- Home page orchestration split by concern (`Home.*.cs` partials + `HomeControls/*`) to reduce merge churn and keep auth/campaign/character/skill flows isolated.
|
||||||
|
- Concern controls now own their local form state and mutation workflows; `Home` handles shared cross-control state refresh.
|
||||||
|
|
||||||
## UX Checklist
|
## UX Checklist
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ Frontend:
|
|||||||
- `RpgRoller/Components/Pages/Home.*.cs`: concern-based partial class split (`State`, `Lifecycle`, `Auth`, `Campaign`, `Character`, `Skill`, `Realtime`, `Api`, `Presentation`, `Validation`)
|
- `RpgRoller/Components/Pages/Home.*.cs`: concern-based partial class split (`State`, `Lifecycle`, `Auth`, `Campaign`, `Character`, `Skill`, `Realtime`, `Api`, `Presentation`, `Validation`)
|
||||||
- `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; `Home` receives typed submissions and returns `FormSubmissionResult` for server/business validation feedback
|
- Form ownership model: controls own transient form/error state and execute their concern-specific API mutations directly
|
||||||
|
- `RpgRoller/Components/RpgRollerApiClient.cs`: shared browser API client used by `Home` and leaf controls
|
||||||
- `RpgRoller/wwwroot/js/rpgroller-api.js`: browser-side API + SSE + session storage interop for Blazor
|
- `RpgRoller/wwwroot/js/rpgroller-api.js`: browser-side API + SSE + session storage interop for Blazor
|
||||||
- `RpgRoller/wwwroot/styles.css`: responsive UX styling and theme tokens
|
- `RpgRoller/wwwroot/styles.css`: responsive UX styling and theme tokens
|
||||||
|
|
||||||
|
|||||||
@@ -1,51 +1,20 @@
|
|||||||
using System.Text.Json;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.JSInterop;
|
using RpgRoller.Components;
|
||||||
|
|
||||||
namespace RpgRoller.Components.Pages;
|
namespace RpgRoller.Components.Pages;
|
||||||
|
|
||||||
public partial class Home
|
public partial class Home
|
||||||
{
|
{
|
||||||
private async Task<T> RequestAsync<T>(string method, string path, object? payload = null)
|
[Inject]
|
||||||
|
private RpgRollerApiClient ApiClient { get; set; } = default!;
|
||||||
|
|
||||||
|
private Task<T> RequestAsync<T>(string method, string path, object? payload = null)
|
||||||
{
|
{
|
||||||
var response = await JS.InvokeAsync<JsApiResponse>("rpgRollerApi.request", method, path, payload);
|
return ApiClient.RequestAsync<T>(method, path, payload);
|
||||||
if (!response.Ok)
|
|
||||||
{
|
|
||||||
throw new ApiRequestException(response.Status, response.Error ?? "Request failed.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.Data.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
|
|
||||||
{
|
|
||||||
return default!;
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.Data.Deserialize<T>(JsonOptions)!;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RequestWithoutPayloadAsync(string method, string path)
|
private Task RequestWithoutPayloadAsync(string method, string path)
|
||||||
{
|
{
|
||||||
var response = await JS.InvokeAsync<JsApiResponse>("rpgRollerApi.request", method, path, null);
|
return ApiClient.RequestWithoutPayloadAsync(method, path);
|
||||||
if (!response.Ok)
|
|
||||||
{
|
|
||||||
throw new ApiRequestException(response.Status, response.Error ?? "Request failed.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class JsApiResponse
|
|
||||||
{
|
|
||||||
public bool Ok { get; set; }
|
|
||||||
public int Status { get; set; }
|
|
||||||
public string? Error { get; set; }
|
|
||||||
public JsonElement Data { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class ApiRequestException : Exception
|
|
||||||
{
|
|
||||||
public ApiRequestException(int statusCode, string message)
|
|
||||||
: base(message)
|
|
||||||
{
|
|
||||||
StatusCode = statusCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int StatusCode { get; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,97 +1,19 @@
|
|||||||
using RpgRoller.Contracts;
|
using RpgRoller.Components;
|
||||||
|
|
||||||
namespace RpgRoller.Components.Pages;
|
namespace RpgRoller.Components.Pages;
|
||||||
|
|
||||||
public partial class Home
|
public partial class Home
|
||||||
{
|
{
|
||||||
private async Task<FormSubmissionResult> RegisterAsync(RegisterFormModel model)
|
private async Task OnLoggedInAsync()
|
||||||
{
|
{
|
||||||
var validationErrors = new Dictionary<string, string>();
|
|
||||||
|
|
||||||
AddRequiredError(validationErrors, "username", model.Username, "Username is required.");
|
|
||||||
AddRequiredError(validationErrors, "displayName", model.DisplayName, "Display name is required.");
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(model.Password) || model.Password.Length < 8)
|
|
||||||
{
|
|
||||||
validationErrors["password"] = "Password must be at least 8 characters.";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (validationErrors.Count > 0)
|
|
||||||
{
|
|
||||||
return new FormSubmissionResult
|
|
||||||
{
|
|
||||||
ErrorMessage = "Resolve validation issues before submitting.",
|
|
||||||
Errors = validationErrors
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
IsMutating = true;
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_ = await RequestAsync<UserSummary>(
|
|
||||||
"POST",
|
|
||||||
"/api/auth/register",
|
|
||||||
new RegisterRequest(model.Username.Trim(), model.Password, model.DisplayName.Trim()));
|
|
||||||
|
|
||||||
SetStatus("Registration successful. You can log in now.", false);
|
|
||||||
return new FormSubmissionResult();
|
|
||||||
}
|
|
||||||
catch (ApiRequestException ex)
|
|
||||||
{
|
|
||||||
if (ex.Message.Contains("already taken", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return new FormSubmissionResult
|
|
||||||
{
|
|
||||||
Errors = new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
["username"] = "Username is already taken. Choose another one."
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return new FormSubmissionResult { ErrorMessage = ex.Message };
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
IsMutating = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<FormSubmissionResult> LoginAsync(LoginFormModel model)
|
|
||||||
{
|
|
||||||
var validationErrors = new Dictionary<string, string>();
|
|
||||||
|
|
||||||
AddRequiredError(validationErrors, "username", model.Username, "Username is required.");
|
|
||||||
AddRequiredError(validationErrors, "password", model.Password, "Password is required.");
|
|
||||||
|
|
||||||
if (validationErrors.Count > 0)
|
|
||||||
{
|
|
||||||
return new FormSubmissionResult
|
|
||||||
{
|
|
||||||
ErrorMessage = "Resolve validation issues before submitting.",
|
|
||||||
Errors = validationErrors
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
IsMutating = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_ = await RequestAsync<UserSummary>(
|
|
||||||
"POST",
|
|
||||||
"/api/auth/login",
|
|
||||||
new LoginRequest(model.Username.Trim(), model.Password));
|
|
||||||
|
|
||||||
await ReloadAuthenticatedSessionAsync(null);
|
await ReloadAuthenticatedSessionAsync(null);
|
||||||
SetStatus("Logged in.", false);
|
SetStatus("Logged in.", false);
|
||||||
return new FormSubmissionResult();
|
|
||||||
}
|
}
|
||||||
catch (ApiRequestException ex)
|
catch (ApiRequestException ex)
|
||||||
{
|
{
|
||||||
return new FormSubmissionResult { ErrorMessage = ex.Message };
|
SetStatus(ex.Message, true);
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
IsMutating = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.JSInterop;
|
using Microsoft.JSInterop;
|
||||||
using RpgRoller.Contracts;
|
|
||||||
|
|
||||||
namespace RpgRoller.Components.Pages;
|
namespace RpgRoller.Components.Pages;
|
||||||
|
|
||||||
@@ -51,43 +50,11 @@ public partial class Home
|
|||||||
await SyncStateEventsAsync();
|
await SyncStateEventsAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<FormSubmissionResult> CreateCampaignAsync(CampaignFormModel model)
|
private async Task OnCampaignCreatedAsync(Guid campaignId)
|
||||||
{
|
{
|
||||||
var validationErrors = new Dictionary<string, string>();
|
await ReloadCampaignsAsync(campaignId);
|
||||||
|
await RefreshCampaignScopeAsync();
|
||||||
AddRequiredError(validationErrors, "name", model.Name, "Campaign name is required.");
|
await SyncStateEventsAsync();
|
||||||
AddRequiredError(validationErrors, "rulesetId", model.RulesetId, "Ruleset is required.");
|
SetStatus("Campaign created.", false);
|
||||||
|
|
||||||
if (validationErrors.Count > 0)
|
|
||||||
{
|
|
||||||
return new FormSubmissionResult
|
|
||||||
{
|
|
||||||
ErrorMessage = "Resolve validation issues before submitting.",
|
|
||||||
Errors = validationErrors
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
IsMutating = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var campaign = await RequestAsync<CampaignSummary>(
|
|
||||||
"POST",
|
|
||||||
"/api/campaigns",
|
|
||||||
new CreateCampaignRequest(model.Name.Trim(), model.RulesetId));
|
|
||||||
|
|
||||||
await ReloadCampaignsAsync(campaign.Id);
|
|
||||||
await RefreshCampaignScopeAsync();
|
|
||||||
await SyncStateEventsAsync();
|
|
||||||
SetStatus("Campaign created.", false);
|
|
||||||
return new FormSubmissionResult();
|
|
||||||
}
|
|
||||||
catch (ApiRequestException ex)
|
|
||||||
{
|
|
||||||
return new FormSubmissionResult { ErrorMessage = ex.Message };
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
IsMutating = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,100 +35,22 @@ public partial class Home
|
|||||||
EditingCharacterId = null;
|
EditingCharacterId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<FormSubmissionResult> CreateCharacterAsync(CharacterFormModel model)
|
private async Task OnCharacterCreatedAsync(Guid campaignId)
|
||||||
{
|
{
|
||||||
var validationErrors = new Dictionary<string, string>();
|
CloseCharacterModals();
|
||||||
|
await ReloadCampaignsAsync(campaignId);
|
||||||
AddRequiredError(validationErrors, "name", model.Name, "Character name is required.");
|
await RefreshCampaignScopeAsync();
|
||||||
var hasCampaignId = TryParseGuid(
|
await SyncStateEventsAsync();
|
||||||
model.CampaignId,
|
SetStatus("Character created.", false);
|
||||||
validationErrors,
|
|
||||||
"campaignId",
|
|
||||||
"Campaign is required.",
|
|
||||||
out var campaignId);
|
|
||||||
|
|
||||||
if (validationErrors.Count > 0 || !hasCampaignId)
|
|
||||||
{
|
|
||||||
return new FormSubmissionResult
|
|
||||||
{
|
|
||||||
ErrorMessage = "Resolve validation issues before submitting.",
|
|
||||||
Errors = validationErrors
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
IsMutating = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_ = await RequestAsync<CharacterSummary>(
|
|
||||||
"POST",
|
|
||||||
"/api/characters",
|
|
||||||
new CreateCharacterRequest(model.Name.Trim(), campaignId));
|
|
||||||
|
|
||||||
CloseCharacterModals();
|
|
||||||
await ReloadCampaignsAsync(campaignId);
|
|
||||||
await RefreshCampaignScopeAsync();
|
|
||||||
await SyncStateEventsAsync();
|
|
||||||
SetStatus("Character created.", false);
|
|
||||||
return new FormSubmissionResult();
|
|
||||||
}
|
|
||||||
catch (ApiRequestException ex)
|
|
||||||
{
|
|
||||||
return new FormSubmissionResult { ErrorMessage = ex.Message };
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
IsMutating = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<FormSubmissionResult> UpdateCharacterAsync(CharacterFormModel model)
|
private async Task OnCharacterUpdatedAsync(Guid campaignId)
|
||||||
{
|
{
|
||||||
if (!EditingCharacterId.HasValue)
|
CloseCharacterModals();
|
||||||
{
|
await ReloadCampaignsAsync(campaignId);
|
||||||
return new FormSubmissionResult { ErrorMessage = "No character selected." };
|
await RefreshCampaignScopeAsync();
|
||||||
}
|
await SyncStateEventsAsync();
|
||||||
|
SetStatus("Character updated.", false);
|
||||||
var validationErrors = new Dictionary<string, string>();
|
|
||||||
AddRequiredError(validationErrors, "name", model.Name, "Character name is required.");
|
|
||||||
var hasCampaignId = TryParseGuid(
|
|
||||||
model.CampaignId,
|
|
||||||
validationErrors,
|
|
||||||
"campaignId",
|
|
||||||
"Campaign is required.",
|
|
||||||
out var campaignId);
|
|
||||||
|
|
||||||
if (validationErrors.Count > 0 || !hasCampaignId)
|
|
||||||
{
|
|
||||||
return new FormSubmissionResult
|
|
||||||
{
|
|
||||||
ErrorMessage = "Resolve validation issues before submitting.",
|
|
||||||
Errors = validationErrors
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
IsMutating = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var updatedCharacter = await RequestAsync<CharacterSummary>(
|
|
||||||
"PUT",
|
|
||||||
$"/api/characters/{EditingCharacterId.Value}",
|
|
||||||
new UpdateCharacterRequest(model.Name.Trim(), campaignId));
|
|
||||||
|
|
||||||
CloseCharacterModals();
|
|
||||||
await ReloadCampaignsAsync(updatedCharacter.CampaignId);
|
|
||||||
await RefreshCampaignScopeAsync();
|
|
||||||
await SyncStateEventsAsync();
|
|
||||||
SetStatus("Character updated.", false);
|
|
||||||
return new FormSubmissionResult();
|
|
||||||
}
|
|
||||||
catch (ApiRequestException ex)
|
|
||||||
{
|
|
||||||
return new FormSubmissionResult { ErrorMessage = ex.Message };
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
IsMutating = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SelectCharacterAsync(Guid characterId)
|
private async Task SelectCharacterAsync(Guid characterId)
|
||||||
|
|||||||
@@ -14,13 +14,6 @@ public sealed class FormState<TModel>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class FormSubmissionResult
|
|
||||||
{
|
|
||||||
public Dictionary<string, string> Errors { get; init; } = [];
|
|
||||||
public string? ErrorMessage { get; init; }
|
|
||||||
public bool IsSuccess => Errors.Count == 0 && string.IsNullOrWhiteSpace(ErrorMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class RegisterFormModel
|
public sealed class RegisterFormModel
|
||||||
{
|
{
|
||||||
public string Username { get; set; } = string.Empty;
|
public string Username { get; set; } = string.Empty;
|
||||||
|
|||||||
@@ -44,101 +44,19 @@ public partial class Home
|
|||||||
EditingSkillId = null;
|
EditingSkillId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<FormSubmissionResult> CreateSkillAsync(SkillFormModel model)
|
private async Task OnSkillCreatedAsync(Guid _)
|
||||||
{
|
{
|
||||||
if (SelectedCharacter is null)
|
CloseSkillModals();
|
||||||
{
|
await RefreshCampaignScopeAsync();
|
||||||
return new FormSubmissionResult { ErrorMessage = "Select a character first." };
|
SetStatus("Skill created.", false);
|
||||||
}
|
|
||||||
|
|
||||||
var validationErrors = new Dictionary<string, string>();
|
|
||||||
AddRequiredError(validationErrors, "name", model.Name, "Skill name is required.");
|
|
||||||
AddRequiredError(validationErrors, "diceRollDefinition", model.DiceRollDefinition, "Expression is required.");
|
|
||||||
|
|
||||||
if (IsSelectedCampaignD6 && model.WildDice < 1)
|
|
||||||
{
|
|
||||||
validationErrors["wildDice"] = "D6 skills require at least one wild die.";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (validationErrors.Count > 0)
|
|
||||||
{
|
|
||||||
return new FormSubmissionResult
|
|
||||||
{
|
|
||||||
ErrorMessage = "Resolve validation issues before submitting.",
|
|
||||||
Errors = validationErrors
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
IsMutating = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_ = await RequestAsync<SkillSummary>(
|
|
||||||
"POST",
|
|
||||||
$"/api/characters/{SelectedCharacter.Id}/skills",
|
|
||||||
new CreateSkillRequest(model.Name.Trim(), model.DiceRollDefinition.Trim(), model.WildDice, model.AllowFumble));
|
|
||||||
|
|
||||||
CloseSkillModals();
|
|
||||||
await RefreshCampaignScopeAsync();
|
|
||||||
SetStatus("Skill created.", false);
|
|
||||||
return new FormSubmissionResult();
|
|
||||||
}
|
|
||||||
catch (ApiRequestException ex)
|
|
||||||
{
|
|
||||||
return new FormSubmissionResult { ErrorMessage = ex.Message };
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
IsMutating = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<FormSubmissionResult> UpdateSkillAsync(SkillFormModel model)
|
private async Task OnSkillUpdatedAsync(Guid skillId)
|
||||||
{
|
{
|
||||||
if (!EditingSkillId.HasValue)
|
SelectedSkillId = skillId;
|
||||||
{
|
CloseSkillModals();
|
||||||
return new FormSubmissionResult { ErrorMessage = "No skill selected." };
|
await RefreshCampaignScopeAsync();
|
||||||
}
|
SetStatus("Skill updated.", false);
|
||||||
|
|
||||||
var validationErrors = new Dictionary<string, string>();
|
|
||||||
AddRequiredError(validationErrors, "name", model.Name, "Skill name is required.");
|
|
||||||
AddRequiredError(validationErrors, "diceRollDefinition", model.DiceRollDefinition, "Expression is required.");
|
|
||||||
|
|
||||||
if (IsSelectedCampaignD6 && model.WildDice < 1)
|
|
||||||
{
|
|
||||||
validationErrors["wildDice"] = "D6 skills require at least one wild die.";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (validationErrors.Count > 0)
|
|
||||||
{
|
|
||||||
return new FormSubmissionResult
|
|
||||||
{
|
|
||||||
ErrorMessage = "Resolve validation issues before submitting.",
|
|
||||||
Errors = validationErrors
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
IsMutating = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var updatedSkill = await RequestAsync<SkillSummary>(
|
|
||||||
"PUT",
|
|
||||||
$"/api/skills/{EditingSkillId.Value}",
|
|
||||||
new UpdateSkillRequest(model.Name.Trim(), model.DiceRollDefinition.Trim(), model.WildDice, model.AllowFumble));
|
|
||||||
|
|
||||||
SelectedSkillId = updatedSkill.Id;
|
|
||||||
CloseSkillModals();
|
|
||||||
await RefreshCampaignScopeAsync();
|
|
||||||
SetStatus("Skill updated.", false);
|
|
||||||
return new FormSubmissionResult();
|
|
||||||
}
|
|
||||||
catch (ApiRequestException ex)
|
|
||||||
{
|
|
||||||
return new FormSubmissionResult { ErrorMessage = ex.Message };
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
IsMutating = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RollSelectedSkillAsync()
|
private async Task RollSelectedSkillAsync()
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.Json;
|
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.JSInterop;
|
using Microsoft.JSInterop;
|
||||||
using RpgRoller.Contracts;
|
using RpgRoller.Contracts;
|
||||||
@@ -15,7 +14,6 @@ public partial class Home
|
|||||||
private const string ScreenSessionKey = "screen";
|
private const string ScreenSessionKey = "screen";
|
||||||
private const string CampaignSessionKey = "campaign";
|
private const string CampaignSessionKey = "campaign";
|
||||||
private const string MobilePanelSessionKey = "play-panel";
|
private const string MobilePanelSessionKey = "play-panel";
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
|
||||||
|
|
||||||
private UserSummary? User { get; set; }
|
private UserSummary? User { get; set; }
|
||||||
private Guid? ActiveCharacterId { get; set; }
|
private Guid? ActiveCharacterId { get; set; }
|
||||||
|
|||||||
@@ -2,22 +2,4 @@ namespace RpgRoller.Components.Pages;
|
|||||||
|
|
||||||
public partial class Home
|
public partial class Home
|
||||||
{
|
{
|
||||||
private static void AddRequiredError(Dictionary<string, string> errors, string field, string? value, string message)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(value))
|
|
||||||
{
|
|
||||||
errors[field] = message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool TryParseGuid(string? rawValue, Dictionary<string, string> errors, string field, string message, out Guid parsed)
|
|
||||||
{
|
|
||||||
if (Guid.TryParse(rawValue, out parsed))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
errors[field] = message;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,11 +27,9 @@
|
|||||||
|
|
||||||
case HomeViewMode.Anonymous:
|
case HomeViewMode.Anonymous:
|
||||||
<AuthSection
|
<AuthSection
|
||||||
IsMutating="IsMutating"
|
|
||||||
StatusMessage="StatusMessage"
|
StatusMessage="StatusMessage"
|
||||||
StatusIsError="StatusIsError"
|
StatusIsError="StatusIsError"
|
||||||
RegisterSubmitted="RegisterAsync"
|
LoggedIn="OnLoggedInAsync" />
|
||||||
LoginSubmitted="LoginAsync" />
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case HomeViewMode.Workspace:
|
case HomeViewMode.Workspace:
|
||||||
@@ -119,7 +117,7 @@
|
|||||||
OwnerLabel="OwnerLabel"
|
OwnerLabel="OwnerLabel"
|
||||||
CanEditCharacter="CanEditCharacter"
|
CanEditCharacter="CanEditCharacter"
|
||||||
CampaignSelectionChanged="OnCampaignSelectionChangedAsync"
|
CampaignSelectionChanged="OnCampaignSelectionChangedAsync"
|
||||||
CreateCampaignSubmitted="CreateCampaignAsync"
|
CampaignCreated="OnCampaignCreatedAsync"
|
||||||
CreateCharacterRequested="OpenCreateCharacterModal"
|
CreateCharacterRequested="OpenCreateCharacterModal"
|
||||||
EditCharacterRequested="OpenEditCharacterModal" />
|
EditCharacterRequested="OpenEditCharacterModal" />
|
||||||
}
|
}
|
||||||
@@ -136,9 +134,10 @@
|
|||||||
CampaignInputId="character-create-campaign"
|
CampaignInputId="character-create-campaign"
|
||||||
InitialModel="CreateCharacterInitialModel"
|
InitialModel="CreateCharacterInitialModel"
|
||||||
FormVersion="CreateCharacterFormVersion"
|
FormVersion="CreateCharacterFormVersion"
|
||||||
|
EditingCharacterId="null"
|
||||||
Campaigns="Campaigns"
|
Campaigns="Campaigns"
|
||||||
IsMutating="IsMutating"
|
IsMutating="IsMutating"
|
||||||
SubmitRequested="CreateCharacterAsync"
|
CharacterSaved="OnCharacterCreatedAsync"
|
||||||
CancelRequested="CloseCharacterModals" />
|
CancelRequested="CloseCharacterModals" />
|
||||||
|
|
||||||
<CharacterFormModal
|
<CharacterFormModal
|
||||||
@@ -149,9 +148,10 @@
|
|||||||
CampaignInputId="character-edit-campaign"
|
CampaignInputId="character-edit-campaign"
|
||||||
InitialModel="EditCharacterInitialModel"
|
InitialModel="EditCharacterInitialModel"
|
||||||
FormVersion="EditCharacterFormVersion"
|
FormVersion="EditCharacterFormVersion"
|
||||||
|
EditingCharacterId="EditingCharacterId"
|
||||||
Campaigns="Campaigns"
|
Campaigns="Campaigns"
|
||||||
IsMutating="IsMutating"
|
IsMutating="IsMutating"
|
||||||
SubmitRequested="UpdateCharacterAsync"
|
CharacterSaved="OnCharacterUpdatedAsync"
|
||||||
CancelRequested="CloseCharacterModals" />
|
CancelRequested="CloseCharacterModals" />
|
||||||
|
|
||||||
<SkillFormModal
|
<SkillFormModal
|
||||||
@@ -165,8 +165,10 @@
|
|||||||
AllowFumbleInputId="skill-create-allow-fumble"
|
AllowFumbleInputId="skill-create-allow-fumble"
|
||||||
InitialModel="CreateSkillInitialModel"
|
InitialModel="CreateSkillInitialModel"
|
||||||
FormVersion="CreateSkillFormVersion"
|
FormVersion="CreateSkillFormVersion"
|
||||||
|
SelectedCharacterId="SelectedCharacterId"
|
||||||
|
EditingSkillId="null"
|
||||||
IsMutating="IsMutating"
|
IsMutating="IsMutating"
|
||||||
SubmitRequested="CreateSkillAsync"
|
SkillSaved="OnSkillCreatedAsync"
|
||||||
CancelRequested="CloseSkillModals" />
|
CancelRequested="CloseSkillModals" />
|
||||||
|
|
||||||
<SkillFormModal
|
<SkillFormModal
|
||||||
@@ -180,6 +182,8 @@
|
|||||||
AllowFumbleInputId="skill-edit-allow-fumble"
|
AllowFumbleInputId="skill-edit-allow-fumble"
|
||||||
InitialModel="EditSkillInitialModel"
|
InitialModel="EditSkillInitialModel"
|
||||||
FormVersion="EditSkillFormVersion"
|
FormVersion="EditSkillFormVersion"
|
||||||
|
SelectedCharacterId="SelectedCharacterId"
|
||||||
|
EditingSkillId="EditingSkillId"
|
||||||
IsMutating="IsMutating"
|
IsMutating="IsMutating"
|
||||||
SubmitRequested="UpdateSkillAsync"
|
SkillSaved="OnSkillUpdatedAsync"
|
||||||
CancelRequested="CloseSkillModals" />
|
CancelRequested="CloseSkillModals" />
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
@using System.Diagnostics.CodeAnalysis
|
@using System.Diagnostics.CodeAnalysis
|
||||||
|
@using RpgRoller.Components
|
||||||
@using RpgRoller.Components.Pages
|
@using RpgRoller.Components.Pages
|
||||||
|
@using RpgRoller.Contracts
|
||||||
@attribute [ExcludeFromCodeCoverage]
|
@attribute [ExcludeFromCodeCoverage]
|
||||||
|
@inject RpgRollerApiClient ApiClient
|
||||||
|
|
||||||
<main class="auth-shell">
|
<main class="auth-shell">
|
||||||
<h1>RpgRoller</h1>
|
<h1>RpgRoller</h1>
|
||||||
@@ -35,7 +38,7 @@
|
|||||||
{
|
{
|
||||||
<p class="field-error">@registerPasswordError</p>
|
<p class="field-error">@registerPasswordError</p>
|
||||||
}
|
}
|
||||||
<button type="submit" disabled="@IsMutating">@(IsMutating ? "Registering..." : "Register")</button>
|
<button type="submit" disabled="@IsSubmitting">@(IsSubmitting ? "Registering..." : "Register")</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -58,7 +61,7 @@
|
|||||||
{
|
{
|
||||||
<p class="field-error">@loginPasswordError</p>
|
<p class="field-error">@loginPasswordError</p>
|
||||||
}
|
}
|
||||||
<button type="submit" disabled="@IsMutating">@(IsMutating ? "Logging in..." : "Login")</button>
|
<button type="submit" disabled="@IsSubmitting">@(IsSubmitting ? "Logging in..." : "Login")</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,9 +70,7 @@
|
|||||||
@code {
|
@code {
|
||||||
private FormState<RegisterFormModel> RegisterState { get; } = new();
|
private FormState<RegisterFormModel> RegisterState { get; } = new();
|
||||||
private FormState<LoginFormModel> LoginState { get; } = new();
|
private FormState<LoginFormModel> LoginState { get; } = new();
|
||||||
|
private bool IsSubmitting { get; set; }
|
||||||
[Parameter]
|
|
||||||
public bool IsMutating { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public string? StatusMessage { get; set; }
|
public string? StatusMessage { get; set; }
|
||||||
@@ -78,26 +79,59 @@
|
|||||||
public bool StatusIsError { get; set; }
|
public bool StatusIsError { get; set; }
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public Func<RegisterFormModel, Task<FormSubmissionResult>> RegisterSubmitted { get; set; } = _ => Task.FromResult(new FormSubmissionResult());
|
public EventCallback LoggedIn { get; set; }
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public Func<LoginFormModel, Task<FormSubmissionResult>> LoginSubmitted { get; set; } = _ => Task.FromResult(new FormSubmissionResult());
|
|
||||||
|
|
||||||
private async Task SubmitRegisterAsync()
|
private async Task SubmitRegisterAsync()
|
||||||
{
|
{
|
||||||
RegisterState.ResetValidation();
|
RegisterState.ResetValidation();
|
||||||
|
|
||||||
var result = await RegisterSubmitted.Invoke(new RegisterFormModel
|
var model = RegisterState.Model;
|
||||||
|
if (string.IsNullOrWhiteSpace(model.Username))
|
||||||
{
|
{
|
||||||
Username = RegisterState.Model.Username,
|
RegisterState.Errors["username"] = "Username is required.";
|
||||||
DisplayName = RegisterState.Model.DisplayName,
|
}
|
||||||
Password = RegisterState.Model.Password
|
|
||||||
});
|
|
||||||
|
|
||||||
ApplyResult(RegisterState, result);
|
if (string.IsNullOrWhiteSpace(model.DisplayName))
|
||||||
if (result.IsSuccess)
|
|
||||||
{
|
{
|
||||||
RegisterState.Model.Password = string.Empty;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,28 +139,41 @@
|
|||||||
{
|
{
|
||||||
LoginState.ResetValidation();
|
LoginState.ResetValidation();
|
||||||
|
|
||||||
var result = await LoginSubmitted.Invoke(new LoginFormModel
|
var model = LoginState.Model;
|
||||||
|
if (string.IsNullOrWhiteSpace(model.Username))
|
||||||
{
|
{
|
||||||
Username = LoginState.Model.Username,
|
LoginState.Errors["username"] = "Username is required.";
|
||||||
Password = LoginState.Model.Password
|
|
||||||
});
|
|
||||||
|
|
||||||
ApplyResult(LoginState, result);
|
|
||||||
if (result.IsSuccess)
|
|
||||||
{
|
|
||||||
LoginState.Model.Password = string.Empty;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ApplyResult<TModel>(FormState<TModel> state, FormSubmissionResult result)
|
|
||||||
where TModel : new()
|
|
||||||
{
|
|
||||||
state.Errors.Clear();
|
|
||||||
foreach (var (key, value) in result.Errors)
|
|
||||||
{
|
|
||||||
state.Errors[key] = value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
state.ErrorMessage = result.ErrorMessage;
|
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,7 +1,9 @@
|
|||||||
@using System.Diagnostics.CodeAnalysis
|
@using System.Diagnostics.CodeAnalysis
|
||||||
|
@using RpgRoller.Components
|
||||||
@using RpgRoller.Components.Pages
|
@using RpgRoller.Components.Pages
|
||||||
@using RpgRoller.Contracts
|
@using RpgRoller.Contracts
|
||||||
@attribute [ExcludeFromCodeCoverage]
|
@attribute [ExcludeFromCodeCoverage]
|
||||||
|
@inject RpgRollerApiClient ApiClient
|
||||||
|
|
||||||
<main class="management-screen">
|
<main class="management-screen">
|
||||||
<section class="card">
|
<section class="card">
|
||||||
@@ -48,7 +50,7 @@
|
|||||||
{
|
{
|
||||||
<p class="field-error">@campaignRulesetError</p>
|
<p class="field-error">@campaignRulesetError</p>
|
||||||
}
|
}
|
||||||
<button type="submit" disabled="@IsMutating">@(IsMutating ? "Creating..." : "Create Campaign")</button>
|
<button type="submit" disabled="@(IsMutating || IsCreatingCampaign)">@(IsCreatingCampaign ? "Creating..." : "Create Campaign")</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -70,7 +72,7 @@
|
|||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h2>Character Management</h2>
|
<h2>Character Management</h2>
|
||||||
<button type="button" disabled="@(IsMutating || SelectedCampaign is null)" @onclick="CreateCharacterRequested">Create Character</button>
|
<button type="button" disabled="@(IsMutating || IsCreatingCampaign || SelectedCampaign is null)" @onclick="CreateCharacterRequested">Create Character</button>
|
||||||
</div>
|
</div>
|
||||||
@if (SelectedCampaign is null)
|
@if (SelectedCampaign is null)
|
||||||
{
|
{
|
||||||
@@ -88,7 +90,7 @@
|
|||||||
<li>
|
<li>
|
||||||
<div><strong>@character.Name</strong><p class="muted">Owner: @OwnerLabel(character.OwnerUserId)</p></div>
|
<div><strong>@character.Name</strong><p class="muted">Owner: @OwnerLabel(character.OwnerUserId)</p></div>
|
||||||
<div class="inline-actions">
|
<div class="inline-actions">
|
||||||
<button type="button" disabled="@(IsMutating || !CanEditCharacter(character))" @onclick="() => EditCharacterRequested.InvokeAsync(character)">Edit</button>
|
<button type="button" disabled="@(IsMutating || IsCreatingCampaign || !CanEditCharacter(character))" @onclick="() => EditCharacterRequested.InvokeAsync(character)">Edit</button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
@@ -99,6 +101,7 @@
|
|||||||
|
|
||||||
@code {
|
@code {
|
||||||
private FormState<CampaignFormModel> CampaignState { get; } = new();
|
private FormState<CampaignFormModel> CampaignState { get; } = new();
|
||||||
|
private bool IsCreatingCampaign { get; set; }
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public IReadOnlyList<CampaignSummary> Campaigns { get; set; } = [];
|
public IReadOnlyList<CampaignSummary> Campaigns { get; set; } = [];
|
||||||
@@ -128,7 +131,7 @@
|
|||||||
public EventCallback<ChangeEventArgs> CampaignSelectionChanged { get; set; }
|
public EventCallback<ChangeEventArgs> CampaignSelectionChanged { get; set; }
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public Func<CampaignFormModel, Task<FormSubmissionResult>> CreateCampaignSubmitted { get; set; } = _ => Task.FromResult(new FormSubmissionResult());
|
public EventCallback<Guid> CampaignCreated { get; set; }
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public EventCallback CreateCharacterRequested { get; set; }
|
public EventCallback CreateCharacterRequested { get; set; }
|
||||||
@@ -148,22 +151,40 @@
|
|||||||
{
|
{
|
||||||
CampaignState.ResetValidation();
|
CampaignState.ResetValidation();
|
||||||
|
|
||||||
var result = await CreateCampaignSubmitted.Invoke(new CampaignFormModel
|
if (string.IsNullOrWhiteSpace(CampaignState.Model.Name))
|
||||||
{
|
{
|
||||||
Name = CampaignState.Model.Name,
|
CampaignState.Errors["name"] = "Campaign name is required.";
|
||||||
RulesetId = CampaignState.Model.RulesetId
|
|
||||||
});
|
|
||||||
|
|
||||||
CampaignState.Errors.Clear();
|
|
||||||
foreach (var (key, value) in result.Errors)
|
|
||||||
{
|
|
||||||
CampaignState.Errors[key] = value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CampaignState.ErrorMessage = result.ErrorMessage;
|
if (string.IsNullOrWhiteSpace(CampaignState.Model.RulesetId))
|
||||||
if (result.IsSuccess)
|
|
||||||
{
|
{
|
||||||
|
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;
|
CampaignState.Model.Name = string.Empty;
|
||||||
|
await CampaignCreated.InvokeAsync(campaign.Id);
|
||||||
|
}
|
||||||
|
catch (ApiRequestException ex)
|
||||||
|
{
|
||||||
|
CampaignState.ErrorMessage = ex.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsCreatingCampaign = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
@using System.Diagnostics.CodeAnalysis
|
@using System.Diagnostics.CodeAnalysis
|
||||||
|
@using RpgRoller.Components
|
||||||
@using RpgRoller.Components.Pages
|
@using RpgRoller.Components.Pages
|
||||||
@using RpgRoller.Contracts
|
@using RpgRoller.Contracts
|
||||||
@attribute [ExcludeFromCodeCoverage]
|
@attribute [ExcludeFromCodeCoverage]
|
||||||
|
@inject RpgRollerApiClient ApiClient
|
||||||
|
|
||||||
@if (Visible)
|
@if (Visible)
|
||||||
{
|
{
|
||||||
@@ -32,7 +34,7 @@
|
|||||||
<p class="field-error">@campaignError</p>
|
<p class="field-error">@campaignError</p>
|
||||||
}
|
}
|
||||||
<div class="inline-actions">
|
<div class="inline-actions">
|
||||||
<button type="submit" disabled="@IsMutating">@SubmitLabel</button>
|
<button type="submit" disabled="@(IsMutating || IsSubmitting)">@SubmitLabel</button>
|
||||||
<button type="button" class="ghost" @onclick="CancelRequested">Cancel</button>
|
<button type="button" class="ghost" @onclick="CancelRequested">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -43,6 +45,7 @@
|
|||||||
@code {
|
@code {
|
||||||
private FormState<CharacterFormModel> FormState { get; } = new();
|
private FormState<CharacterFormModel> FormState { get; } = new();
|
||||||
private int AppliedFormVersion { get; set; } = -1;
|
private int AppliedFormVersion { get; set; } = -1;
|
||||||
|
private bool IsSubmitting { get; set; }
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public bool Visible { get; set; }
|
public bool Visible { get; set; }
|
||||||
@@ -65,6 +68,9 @@
|
|||||||
[Parameter]
|
[Parameter]
|
||||||
public int FormVersion { get; set; }
|
public int FormVersion { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public Guid? EditingCharacterId { get; set; }
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public IReadOnlyList<CampaignSummary> Campaigns { get; set; } = [];
|
public IReadOnlyList<CampaignSummary> Campaigns { get; set; } = [];
|
||||||
|
|
||||||
@@ -72,7 +78,7 @@
|
|||||||
public bool IsMutating { get; set; }
|
public bool IsMutating { get; set; }
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public Func<CharacterFormModel, Task<FormSubmissionResult>> SubmitRequested { get; set; } = _ => Task.FromResult(new FormSubmissionResult());
|
public EventCallback<Guid> CharacterSaved { get; set; }
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public EventCallback CancelRequested { get; set; }
|
public EventCallback CancelRequested { get; set; }
|
||||||
@@ -94,17 +100,50 @@
|
|||||||
{
|
{
|
||||||
FormState.ResetValidation();
|
FormState.ResetValidation();
|
||||||
|
|
||||||
var result = await SubmitRequested.Invoke(new CharacterFormModel
|
if (string.IsNullOrWhiteSpace(FormState.Model.Name))
|
||||||
{
|
{
|
||||||
Name = FormState.Model.Name,
|
FormState.Errors["name"] = "Character name is required.";
|
||||||
CampaignId = FormState.Model.CampaignId
|
|
||||||
});
|
|
||||||
|
|
||||||
foreach (var (key, value) in result.Errors)
|
|
||||||
{
|
|
||||||
FormState.Errors[key] = value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
FormState.ErrorMessage = result.ErrorMessage;
|
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,6 +1,9 @@
|
|||||||
@using System.Diagnostics.CodeAnalysis
|
@using System.Diagnostics.CodeAnalysis
|
||||||
|
@using RpgRoller.Components
|
||||||
@using RpgRoller.Components.Pages
|
@using RpgRoller.Components.Pages
|
||||||
|
@using RpgRoller.Contracts
|
||||||
@attribute [ExcludeFromCodeCoverage]
|
@attribute [ExcludeFromCodeCoverage]
|
||||||
|
@inject RpgRollerApiClient ApiClient
|
||||||
|
|
||||||
@if (Visible)
|
@if (Visible)
|
||||||
{
|
{
|
||||||
@@ -36,7 +39,7 @@
|
|||||||
<input id="@AllowFumbleInputId" type="checkbox" @bind="FormState.Model.AllowFumble" />
|
<input id="@AllowFumbleInputId" type="checkbox" @bind="FormState.Model.AllowFumble" />
|
||||||
}
|
}
|
||||||
<div class="inline-actions">
|
<div class="inline-actions">
|
||||||
<button type="submit" disabled="@IsMutating">@SubmitLabel</button>
|
<button type="submit" disabled="@(IsMutating || IsSubmitting)">@SubmitLabel</button>
|
||||||
<button type="button" class="ghost" @onclick="CancelRequested">Cancel</button>
|
<button type="button" class="ghost" @onclick="CancelRequested">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -47,6 +50,7 @@
|
|||||||
@code {
|
@code {
|
||||||
private FormState<SkillFormModel> FormState { get; } = new();
|
private FormState<SkillFormModel> FormState { get; } = new();
|
||||||
private int AppliedFormVersion { get; set; } = -1;
|
private int AppliedFormVersion { get; set; } = -1;
|
||||||
|
private bool IsSubmitting { get; set; }
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public bool Visible { get; set; }
|
public bool Visible { get; set; }
|
||||||
@@ -78,11 +82,17 @@
|
|||||||
[Parameter]
|
[Parameter]
|
||||||
public int FormVersion { get; set; }
|
public int FormVersion { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public Guid? SelectedCharacterId { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public Guid? EditingSkillId { get; set; }
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public bool IsMutating { get; set; }
|
public bool IsMutating { get; set; }
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public Func<SkillFormModel, Task<FormSubmissionResult>> SubmitRequested { get; set; } = _ => Task.FromResult(new FormSubmissionResult());
|
public EventCallback<Guid> SkillSaved { get; set; }
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public EventCallback CancelRequested { get; set; }
|
public EventCallback CancelRequested { get; set; }
|
||||||
@@ -106,19 +116,69 @@
|
|||||||
{
|
{
|
||||||
FormState.ResetValidation();
|
FormState.ResetValidation();
|
||||||
|
|
||||||
var result = await SubmitRequested.Invoke(new SkillFormModel
|
if (string.IsNullOrWhiteSpace(FormState.Model.Name))
|
||||||
{
|
{
|
||||||
Name = FormState.Model.Name,
|
FormState.Errors["name"] = "Skill name is required.";
|
||||||
DiceRollDefinition = FormState.Model.DiceRollDefinition,
|
|
||||||
WildDice = FormState.Model.WildDice,
|
|
||||||
AllowFumble = FormState.Model.AllowFumble
|
|
||||||
});
|
|
||||||
|
|
||||||
foreach (var (key, value) in result.Errors)
|
|
||||||
{
|
|
||||||
FormState.Errors[key] = value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
FormState.ErrorMessage = result.ErrorMessage;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
59
RpgRoller/Components/RpgRollerApiClient.cs
Normal file
59
RpgRoller/Components/RpgRollerApiClient.cs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.JSInterop;
|
||||||
|
|
||||||
|
namespace RpgRoller.Components;
|
||||||
|
|
||||||
|
public sealed class RpgRollerApiClient
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||||
|
private readonly IJSRuntime m_Js;
|
||||||
|
|
||||||
|
public RpgRollerApiClient(IJSRuntime js)
|
||||||
|
{
|
||||||
|
m_Js = js;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<T> RequestAsync<T>(string method, string path, object? payload = null)
|
||||||
|
{
|
||||||
|
var response = await m_Js.InvokeAsync<JsApiResponse>("rpgRollerApi.request", method, path, payload);
|
||||||
|
if (!response.Ok)
|
||||||
|
{
|
||||||
|
throw new ApiRequestException(response.Status, response.Error ?? "Request failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.Data.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
|
||||||
|
{
|
||||||
|
return default!;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Data.Deserialize<T>(JsonOptions)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RequestWithoutPayloadAsync(string method, string path)
|
||||||
|
{
|
||||||
|
var response = await m_Js.InvokeAsync<JsApiResponse>("rpgRollerApi.request", method, path, null);
|
||||||
|
if (!response.Ok)
|
||||||
|
{
|
||||||
|
throw new ApiRequestException(response.Status, response.Error ?? "Request failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class JsApiResponse
|
||||||
|
{
|
||||||
|
public bool Ok { get; set; }
|
||||||
|
public int Status { get; set; }
|
||||||
|
public string? Error { get; set; }
|
||||||
|
public JsonElement Data { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ApiRequestException : Exception
|
||||||
|
{
|
||||||
|
public ApiRequestException(int statusCode, string message)
|
||||||
|
: base(message)
|
||||||
|
{
|
||||||
|
StatusCode = statusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int StatusCode { get; }
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ var builder = WebApplication.CreateBuilder(args);
|
|||||||
builder.Services.AddRpgRollerCore(builder.Configuration, builder.Environment);
|
builder.Services.AddRpgRollerCore(builder.Configuration, builder.Environment);
|
||||||
builder.Services.AddRazorComponents()
|
builder.Services.AddRazorComponents()
|
||||||
.AddInteractiveServerComponents();
|
.AddInteractiveServerComponents();
|
||||||
|
builder.Services.AddScoped<RpgRoller.Components.RpgRollerApiClient>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
app.InitializeRpgRollerState();
|
app.InitializeRpgRollerState();
|
||||||
|
|||||||
3
TECH.md
3
TECH.md
@@ -94,7 +94,8 @@ This pattern is a strong baseline for low to medium scale and should be the defa
|
|||||||
- 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`.
|
||||||
- Home page logic split by concern with partials (`Home.State/Auth/Campaign/Character/Skill/Lifecycle/Realtime/Api/Presentation/Validation.cs`) to keep churn localized.
|
- Home page logic split by concern with partials (`Home.State/Auth/Campaign/Character/Skill/Lifecycle/Realtime/Api/Presentation/Validation.cs`) to keep churn localized.
|
||||||
- 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`.
|
||||||
- `Home` workflows return `FormSubmissionResult` so controls can render field and summary errors without parent-owned form wiring.
|
- Concern controls execute their own auth/campaign/character/skill mutation workflows and notify `Home` only for shared-state refresh/orchestration.
|
||||||
|
- Shared browser API interop is centralized in `RpgRollerApiClient` and reused by `Home` plus concern controls.
|
||||||
- Browser API calls and SSE are handled via `wwwroot/js/rpgroller-api.js` interop.
|
- Browser API calls and SSE are handled via `wwwroot/js/rpgroller-api.js` interop.
|
||||||
- UI state is maintained server-side per circuit with session/tab persistence for campaign + screen selection.
|
- UI state is maintained server-side per circuit with session/tab persistence for campaign + screen selection.
|
||||||
- SSE-driven campaign refresh with reconnect backoff and explicit offline/manual-refresh fallback.
|
- SSE-driven campaign refresh with reconnect backoff and explicit offline/manual-refresh fallback.
|
||||||
|
|||||||
Reference in New Issue
Block a user